diff --git a/benchmarks/jmh/benchmarks/jmh/build/results/jmh/aggregate-results.txt b/benchmarks/jmh/benchmarks/jmh/build/results/jmh/aggregate-results.txt
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/benchmarks/jmh/run-benchmark.sh b/benchmarks/jmh/run-benchmark.sh
new file mode 100755
index 00000000000..87bf9725ed2
--- /dev/null
+++ b/benchmarks/jmh/run-benchmark.sh
@@ -0,0 +1,27 @@
+#!/bin/bash
+set -euo pipefail
+
+RESULTS_DIR="benchmarks/jmh/build/results/jmh"
+AGGREGATE_FILE="$RESULTS_DIR/aggregate-results.txt"
+mkdir -p "$RESULTS_DIR"
+
+: > "$AGGREGATE_FILE"
+
+for threads in 10 50 200 500 1000; do
+ echo "=== Running with threads=$threads ==="
+ ./gradlew :benchmarks:jmh:jmh \
+ -Pjmh.includes=HttpsConnectionBenchmark \
+ -Pjmh.profilers=gc \
+ -Pjmh.iterations=3 \
+ -Pjmh.warmupIterations=1 \
+ -Pjmh.fork=1 \
+ -Pjmh.threads="$threads"
+
+ echo "" >> "$AGGREGATE_FILE"
+ echo "=== threads=$threads ===" >> "$AGGREGATE_FILE"
+ cat "$RESULTS_DIR/results.txt" >> "$AGGREGATE_FILE"
+done
+
+echo ""
+echo "=== Aggregated results ==="
+cat "$AGGREGATE_FILE"
diff --git a/core/src/main/java/com/linecorp/armeria/internal/server/DefaultServiceRequestContext.java b/core/src/main/java/com/linecorp/armeria/internal/server/DefaultServiceRequestContext.java
index ef5999036d7..05bea892fdf 100644
--- a/core/src/main/java/com/linecorp/armeria/internal/server/DefaultServiceRequestContext.java
+++ b/core/src/main/java/com/linecorp/armeria/internal/server/DefaultServiceRequestContext.java
@@ -63,6 +63,7 @@
import com.linecorp.armeria.internal.common.NonWrappingRequestContext;
import com.linecorp.armeria.internal.common.util.TemporaryThreadLocals;
import com.linecorp.armeria.internal.server.RouteDecoratingService.InitialDispatcherService;
+import com.linecorp.armeria.server.ConnectionContext;
import com.linecorp.armeria.server.HttpService;
import com.linecorp.armeria.server.ProxiedAddresses;
import com.linecorp.armeria.server.Route;
@@ -107,6 +108,7 @@ public final class DefaultServiceRequestContext
private final InetAddress clientAddress;
private final InetSocketAddress remoteAddress;
private final InetSocketAddress localAddress;
+ private final ConnectionContext connectionContext;
private boolean shouldReportUnloggedExceptions = true;
@@ -151,12 +153,14 @@ public DefaultServiceRequestContext(
RoutingResult routingResult, ExchangeType exchangeType,
HttpRequest req, @Nullable SSLSession sslSession, ProxiedAddresses proxiedAddresses,
InetAddress clientAddress, InetSocketAddress remoteAddress, InetSocketAddress localAddress,
+ ConnectionContext connectionContext,
long requestStartTimeNanos, long requestStartTimeMicros,
Supplier extends AutoCloseable> contextHook) {
this(cfg, ch, eventLoop, meterRegistry, sessionProtocol, id, routingContext, routingResult,
exchangeType, req, sslSession, proxiedAddresses, clientAddress, remoteAddress, localAddress,
- null /* requestCancellationScheduler */, requestStartTimeNanos, requestStartTimeMicros,
+ connectionContext, null /* requestCancellationScheduler */,
+ requestStartTimeNanos, requestStartTimeMicros,
HttpHeaders.of(), HttpHeaders.of(), contextHook);
}
@@ -166,6 +170,7 @@ public DefaultServiceRequestContext(
RoutingResult routingResult, ExchangeType exchangeType,
HttpRequest req, @Nullable SSLSession sslSession, ProxiedAddresses proxiedAddresses,
InetAddress clientAddress, InetSocketAddress remoteAddress, InetSocketAddress localAddress,
+ ConnectionContext connectionContext,
@Nullable CancellationScheduler requestCancellationScheduler,
long requestStartTimeNanos, long requestStartTimeMicros,
HttpHeaders additionalResponseHeaders, HttpHeaders additionalResponseTrailers,
@@ -196,6 +201,7 @@ public DefaultServiceRequestContext(
this.clientAddress = requireNonNull(clientAddress, "clientAddress");
this.remoteAddress = requireNonNull(remoteAddress, "remoteAddress");
this.localAddress = requireNonNull(localAddress, "localAddress");
+ this.connectionContext = requireNonNull(connectionContext, "connectionContext");
log = RequestLog.builder(this);
log.startRequest(requestStartTimeNanos, requestStartTimeMicros);
@@ -255,11 +261,16 @@ public InetAddress clientAddress() {
return clientAddress;
}
- @Override
+ @Nullable
protected Channel channel() {
return ch;
}
+ @Override
+ public ConnectionContext connectionContext() {
+ return connectionContext;
+ }
+
@Override
public ServiceConfig config() {
return cfg;
diff --git a/core/src/main/java/com/linecorp/armeria/server/ConnectionAcceptHandler.java b/core/src/main/java/com/linecorp/armeria/server/ConnectionAcceptHandler.java
new file mode 100644
index 00000000000..8be9be8f033
--- /dev/null
+++ b/core/src/main/java/com/linecorp/armeria/server/ConnectionAcceptHandler.java
@@ -0,0 +1,328 @@
+/*
+ * Copyright 2025 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you 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:
+ *
+ * https://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 com.linecorp.armeria.server;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.linecorp.armeria.common.SessionProtocol;
+import com.linecorp.armeria.common.annotation.Nullable;
+import com.linecorp.armeria.internal.common.SslContextFactory;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.ByteBufAllocator;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.handler.codec.DecoderException;
+import io.netty.handler.ssl.SniCompletionEvent;
+import io.netty.handler.ssl.SslClientHelloHandler;
+import io.netty.handler.ssl.SslContext;
+import io.netty.handler.ssl.SslHandler;
+import io.netty.util.CharsetUtil;
+import io.netty.util.ReferenceCountUtil;
+import io.netty.util.concurrent.Future;
+import io.netty.util.concurrent.Promise;
+
+/**
+ * A handler that extends {@link SslClientHelloHandler} to inspect the TLS ClientHello,
+ * extract SNI hostname and offered ALPN protocols, and create a {@link ConnectionContext}
+ * before TLS negotiation starts.
+ *
+ *
The {@link ConnectionContext} is stored on the channel and passed to
+ * {@link ServerTlsProvider#serverTlsSpec(ConnectionContext)} for TLS resolution.
+ */
+final class ConnectionAcceptHandler extends SslClientHelloHandler {
+
+ private static final Logger logger = LoggerFactory.getLogger(ConnectionAcceptHandler.class);
+
+ // TLS extension types
+ private static final int EXT_SERVER_NAME = 0x0000;
+ private static final int EXT_ALPN = 0x0010;
+
+ // Server name type for hostname
+ private static final int SERVER_NAME_TYPE_HOSTNAME = 0;
+
+ @Nullable
+ private final ConnectionAcceptor connectionAcceptor;
+ private final ServerTlsProvider serverTlsProvider;
+ private final SslContextFactory sslContextFactory;
+ @Nullable
+ private final ProxiedAddresses proxiedAddresses;
+ private final long handshakeTimeoutMillis;
+
+ @Nullable
+ private String sniHostname;
+
+ ConnectionAcceptHandler(@Nullable ConnectionAcceptor connectionAcceptor,
+ ServerTlsProvider serverTlsProvider,
+ SslContextFactory sslContextFactory,
+ @Nullable ProxiedAddresses proxiedAddresses,
+ int maxClientHelloLength, long handshakeTimeoutMillis) {
+ super(maxClientHelloLength);
+ this.connectionAcceptor = connectionAcceptor;
+ this.serverTlsProvider = serverTlsProvider;
+ this.sslContextFactory = sslContextFactory;
+ this.proxiedAddresses = proxiedAddresses;
+ this.handshakeTimeoutMillis = handshakeTimeoutMillis;
+ }
+
+ @Override
+ protected Future lookup(ChannelHandlerContext ctx,
+ @Nullable ByteBuf clientHello) throws Exception {
+ final SessionProtocol sessionProtocol =
+ clientHello != null ? SessionProtocol.HTTPS : SessionProtocol.HTTP;
+ String sniHostname = "";
+ List alpnProtocols = null;
+
+ if (clientHello != null) {
+ final ClientHelloInfo info = parseClientHello(
+ clientHello, clientHello.readerIndex(),
+ clientHello.readerIndex() + clientHello.readableBytes());
+ if (info.sniHostname != null) {
+ sniHostname = info.sniHostname;
+ }
+ alpnProtocols = info.alpnProtocols;
+ }
+
+ this.sniHostname = sniHostname;
+
+ final ConnectionContext connectionCtx =
+ new ConnectionContext(sessionProtocol, sniHostname, alpnProtocols,
+ proxiedAddresses, ctx.channel());
+ ctx.channel().attr(ConnectionContext.ATTR).set(connectionCtx);
+
+ // Run the acceptor asynchronously. TLS resolution happens in onLookupComplete.
+ final Promise promise = ctx.executor().newPromise();
+
+ if (connectionAcceptor != null) {
+ connectionAcceptor.accept(connectionCtx).whenCompleteAsync((accepted, t) -> {
+ if (t != null) {
+ promise.setFailure(t);
+ } else if (Boolean.FALSE.equals(accepted)) {
+ promise.setSuccess(null); // null signals rejection
+ } else {
+ promise.setSuccess(connectionCtx);
+ }
+ }, ctx.executor());
+ } else {
+ promise.setSuccess(connectionCtx);
+ }
+
+ return promise;
+ }
+
+ @Override
+ protected void onLookupComplete(ChannelHandlerContext ctx,
+ Future future) throws Exception {
+ if (!future.isSuccess()) {
+ ctx.fireUserEventTriggered(new SniCompletionEvent(future.cause()));
+ throw new DecoderException("Connection acceptance failed", future.cause());
+ }
+
+ final ConnectionContext connectionCtx = future.getNow();
+ if (connectionCtx == null) {
+ // Rejected by acceptor.
+ ctx.close();
+ return;
+ }
+
+ // Resolve TLS asynchronously.
+ serverTlsProvider.serverTlsSpec(connectionCtx).whenCompleteAsync((spec, t) -> {
+ if (t != null) {
+ ctx.fireUserEventTriggered(new SniCompletionEvent(t));
+ ctx.fireExceptionCaught(new DecoderException("TLS resolution failed", t));
+ return;
+ }
+ if (spec != null) {
+ try {
+ final SslContext sslContext = sslContextFactory.getOrCreate(spec);
+ ctx.channel().closeFuture().addListener(f -> sslContextFactory.release(sslContext));
+ replaceHandler(ctx, sslContext);
+ ctx.fireUserEventTriggered(new SniCompletionEvent(sniHostname));
+ } catch (Exception e) {
+ ctx.fireUserEventTriggered(new SniCompletionEvent(e));
+ ctx.fireExceptionCaught(new DecoderException("SSL context creation failed", e));
+ }
+ } else {
+ logger.debug("{} ServerTlsProvider returned null; closing.", ctx.channel());
+ ctx.close();
+ }
+ }, ctx.executor());
+ }
+
+ private void replaceHandler(ChannelHandlerContext ctx, SslContext sslContext) {
+ SslHandler sslHandler = null;
+ try {
+ sslHandler = newSslHandler(sslContext, ctx.alloc());
+ ctx.pipeline().replace(this, SslHandler.class.getName(), sslHandler);
+ sslHandler = null;
+ } finally {
+ if (sslHandler != null) {
+ ReferenceCountUtil.safeRelease(sslHandler.engine());
+ }
+ }
+ }
+
+ private SslHandler newSslHandler(SslContext sslContext, ByteBufAllocator allocator) {
+ final SslHandler sslHandler = sslContext.newHandler(allocator);
+ if (handshakeTimeoutMillis > 0) {
+ sslHandler.setHandshakeTimeoutMillis(handshakeTimeoutMillis);
+ }
+ return sslHandler;
+ }
+
+ // ------------------------------------------------------------------
+ // ClientHello parsing (SNI + ALPN)
+ // ------------------------------------------------------------------
+
+ static ClientHelloInfo parseClientHello(ByteBuf in, int offset, int endOffset) {
+ String sniHostname = null;
+ List alpnProtocols = null;
+
+ // Skip client_version (2) + random (32) = 34 bytes
+ offset += 34;
+
+ if (endOffset - offset < 6) {
+ return new ClientHelloInfo(null, null);
+ }
+
+ // Skip session_id
+ final int sessionIdLength = in.getUnsignedByte(offset);
+ offset += sessionIdLength + 1;
+
+ if (endOffset - offset < 2) {
+ return new ClientHelloInfo(null, null);
+ }
+
+ // Skip cipher_suites
+ final int cipherSuitesLength = in.getUnsignedShort(offset);
+ offset += cipherSuitesLength + 2;
+
+ if (endOffset - offset < 1) {
+ return new ClientHelloInfo(null, null);
+ }
+
+ // Skip compression_methods
+ final int compressionMethodLength = in.getUnsignedByte(offset);
+ offset += compressionMethodLength + 1;
+
+ if (endOffset - offset < 2) {
+ return new ClientHelloInfo(null, null);
+ }
+
+ // Parse extensions
+ final int extensionsLength = in.getUnsignedShort(offset);
+ offset += 2;
+ final int extensionsLimit = Math.min(offset + extensionsLength, endOffset);
+
+ while (extensionsLimit - offset >= 4) {
+ final int extensionType = in.getUnsignedShort(offset);
+ offset += 2;
+ final int extensionLength = in.getUnsignedShort(offset);
+ offset += 2;
+
+ if (extensionsLimit - offset < extensionLength) {
+ break;
+ }
+
+ if (extensionType == EXT_SERVER_NAME) {
+ sniHostname = parseSniExtension(in, offset, offset + extensionLength);
+ } else if (extensionType == EXT_ALPN) {
+ alpnProtocols = parseAlpnExtension(in, offset, offset + extensionLength);
+ }
+
+ offset += extensionLength;
+
+ // Early exit if we found both
+ if (sniHostname != null && alpnProtocols != null) {
+ break;
+ }
+ }
+
+ return new ClientHelloInfo(sniHostname, alpnProtocols);
+ }
+
+ @Nullable
+ private static String parseSniExtension(ByteBuf in, int offset, int endOffset) {
+ // server_name_list_length (2)
+ if (endOffset - offset < 2) {
+ return null;
+ }
+ offset += 2;
+
+ if (endOffset - offset < 3) {
+ return null;
+ }
+
+ final int serverNameType = in.getUnsignedByte(offset);
+ offset++;
+
+ if (serverNameType != SERVER_NAME_TYPE_HOSTNAME) {
+ return null;
+ }
+
+ final int serverNameLength = in.getUnsignedShort(offset);
+ offset += 2;
+
+ if (endOffset - offset < serverNameLength) {
+ return null;
+ }
+
+ return in.toString(offset, serverNameLength, CharsetUtil.US_ASCII)
+ .toLowerCase(Locale.US);
+ }
+
+ @Nullable
+ private static List parseAlpnExtension(ByteBuf in, int offset, int endOffset) {
+ // protocol_name_list_length (2)
+ if (endOffset - offset < 2) {
+ return null;
+ }
+ final int listLength = in.getUnsignedShort(offset);
+ offset += 2;
+ final int listEnd = Math.min(offset + listLength, endOffset);
+
+ final List protocols = new ArrayList<>(4);
+ while (listEnd - offset >= 1) {
+ final int nameLength = in.getUnsignedByte(offset);
+ offset++;
+ if (listEnd - offset < nameLength) {
+ break;
+ }
+ protocols.add(in.toString(offset, nameLength, CharsetUtil.US_ASCII));
+ offset += nameLength;
+ }
+
+ return protocols.isEmpty() ? null : Collections.unmodifiableList(protocols);
+ }
+
+ static final class ClientHelloInfo {
+ @Nullable
+ final String sniHostname;
+ @Nullable
+ final List alpnProtocols;
+
+ ClientHelloInfo(@Nullable String sniHostname, @Nullable List alpnProtocols) {
+ this.sniHostname = sniHostname;
+ this.alpnProtocols = alpnProtocols;
+ }
+ }
+}
diff --git a/core/src/main/java/com/linecorp/armeria/server/ConnectionAcceptor.java b/core/src/main/java/com/linecorp/armeria/server/ConnectionAcceptor.java
new file mode 100644
index 00000000000..81970b0970b
--- /dev/null
+++ b/core/src/main/java/com/linecorp/armeria/server/ConnectionAcceptor.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2025 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you 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:
+ *
+ * https://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 com.linecorp.armeria.server;
+
+import java.util.concurrent.CompletableFuture;
+
+import com.linecorp.armeria.common.annotation.UnstableApi;
+
+/**
+ * Evaluates whether to accept a newly established connection. Called once per connection,
+ * before TLS negotiation, with access to connection-level properties parsed from
+ * the TLS ClientHello (SNI hostname, ALPN protocols, etc.).
+ *
+ * Implementations should evaluate the current policy and store any resolved state
+ * (e.g., matched filter chain, decorator) on the {@link ConnectionContext} via
+ * {@link ConnectionContext#setAttr(io.netty.util.AttributeKey, Object)} for use by
+ * downstream handlers such as {@link ServerTlsProvider} and service decorators.
+ *
+ *
Policy is bound at connection time: the acceptor reads the current snapshot once,
+ * and the stored state is immutable for the connection's lifetime. Subsequent snapshot
+ * updates only affect new connections.
+ *
+ *
To compose with an existing acceptor, read it first via
+ * {@link ServerBuilder#connectionAcceptor()} and delegate to it:
+ *
{@code
+ * ConnectionAcceptor existing = sb.connectionAcceptor();
+ * sb.connectionAcceptor(ctx -> {
+ * if ("blocked.example.com".equals(ctx.sniHostname())) {
+ * return UnmodifiableFuture.completedFuture(false); // reject
+ * }
+ * ctx.setAttr(MY_POLICY_KEY, resolvePolicy(ctx));
+ * if (existing != null) {
+ * return existing.accept(ctx);
+ * }
+ * return UnmodifiableFuture.completedFuture(true);
+ * });
+ * }
+ */
+@UnstableApi
+@FunctionalInterface
+public interface ConnectionAcceptor {
+
+ /**
+ * Evaluates whether to accept the given connection.
+ *
+ * This method is called once per connection, before TLS negotiation.
+ *
+ * @param ctx the connection context with SNI hostname, ALPN protocols,
+ * and attribute access for storing resolved policy
+ * @return a future resolving to {@code true} to accept or {@code false} to reject
+ * (close immediately)
+ */
+ CompletableFuture accept(ConnectionContext ctx);
+}
diff --git a/core/src/main/java/com/linecorp/armeria/server/ConnectionContext.java b/core/src/main/java/com/linecorp/armeria/server/ConnectionContext.java
new file mode 100644
index 00000000000..bc0dc0a510d
--- /dev/null
+++ b/core/src/main/java/com/linecorp/armeria/server/ConnectionContext.java
@@ -0,0 +1,160 @@
+/*
+ * Copyright 2025 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you 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:
+ *
+ * https://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 com.linecorp.armeria.server;
+
+import static java.util.Objects.requireNonNull;
+
+import java.net.InetSocketAddress;
+import java.util.List;
+
+import com.linecorp.armeria.common.ConcurrentAttributes;
+import com.linecorp.armeria.common.SessionProtocol;
+import com.linecorp.armeria.common.annotation.Nullable;
+import com.linecorp.armeria.common.annotation.UnstableApi;
+
+import io.netty.channel.Channel;
+import io.netty.util.AttributeKey;
+
+/**
+ * A read-only context representing a newly accepted connection. Provides connection-level
+ * properties parsed from the TLS ClientHello (for TLS connections) and attribute storage
+ * for passing per-connection state through the server pipeline.
+ *
+ * A {@link ConnectionContext} is created by the server pipeline for each connection and
+ * is passed to {@link ServerTlsProvider#serverTlsSpec(ConnectionContext)
+ * ServerTlsProvider.serverTlsSpec()} for TLS resolution. It is also accessible from
+ * {@link ServiceRequestContext#connectionContext()} so that service decorators can access
+ * connection-level state at request time.
+ */
+@UnstableApi
+public final class ConnectionContext {
+
+ static final AttributeKey ATTR =
+ AttributeKey.valueOf(ConnectionContext.class, "CONNECTION_CONTEXT");
+
+ private final SessionProtocol sessionProtocol;
+ private final String sniHostname;
+ @Nullable
+ private final List alpnProtocols;
+ @Nullable
+ private final ProxiedAddresses proxiedAddresses;
+ private final Channel channel;
+ private final ConcurrentAttributes attrs = ConcurrentAttributes.of();
+ private long maxConnectionAgeMillis;
+
+ /**
+ * Returns the {@link ConnectionContext} stored on the given {@link Channel}, or {@code null}.
+ */
+ @Nullable
+ public static ConnectionContext get(Channel channel) {
+ return channel.attr(ATTR).get();
+ }
+
+ ConnectionContext(SessionProtocol sessionProtocol, String sniHostname,
+ @Nullable List alpnProtocols,
+ @Nullable ProxiedAddresses proxiedAddresses, Channel channel) {
+ this.sessionProtocol = requireNonNull(sessionProtocol, "sessionProtocol");
+ this.sniHostname = sniHostname;
+ this.alpnProtocols = alpnProtocols;
+ this.proxiedAddresses = proxiedAddresses;
+ this.channel = channel;
+ }
+
+ /**
+ * Returns the {@link SessionProtocol} of this connection.
+ */
+ public SessionProtocol sessionProtocol() {
+ return sessionProtocol;
+ }
+
+ /**
+ * Returns the SNI hostname from the TLS ClientHello, or an empty string if
+ * the connection is not TLS or no SNI was provided.
+ */
+ public String sniHostname() {
+ return sniHostname;
+ }
+
+ /**
+ * Returns the ALPN protocols offered in the TLS ClientHello, or {@code null}
+ * if the connection is not TLS or no ALPN extension was present.
+ */
+ @Nullable
+ public List alpnProtocols() {
+ return alpnProtocols;
+ }
+
+ /**
+ * Returns the proxied addresses of this connection from the PROXY protocol,
+ * or {@code null} if the connection did not use the PROXY protocol.
+ */
+ @Nullable
+ public ProxiedAddresses proxiedAddresses() {
+ return proxiedAddresses;
+ }
+
+ /**
+ * Returns the local address of this connection.
+ */
+ public InetSocketAddress localAddress() {
+ return (InetSocketAddress) channel.localAddress();
+ }
+
+ /**
+ * Returns the remote address of this connection.
+ */
+ public InetSocketAddress remoteAddress() {
+ return (InetSocketAddress) channel.remoteAddress();
+ }
+
+ /**
+ * Returns the per-connection max connection age in milliseconds, or {@code 0} if not set.
+ * When set, this value takes precedence over {@link ServerConfig#maxConnectionAgeMillis()}.
+ */
+ public long maxConnectionAgeMillis() {
+ return maxConnectionAgeMillis;
+ }
+
+ /**
+ * Sets the per-connection max connection age in milliseconds.
+ */
+ public void setMaxConnectionAgeMillis(long maxConnectionAgeMillis) {
+ this.maxConnectionAgeMillis = maxConnectionAgeMillis;
+ }
+
+ /**
+ * Returns the value associated with the given {@link AttributeKey}, or {@code null} if not set.
+ */
+ @Nullable
+ public T attr(AttributeKey key) {
+ return attrs.attr(key);
+ }
+
+ /**
+ * Sets the value associated with the given {@link AttributeKey}.
+ */
+ public void setAttr(AttributeKey key, @Nullable T value) {
+ attrs.set(key, value);
+ }
+
+ /**
+ * Returns the Netty {@link Channel} for this connection.
+ */
+ Channel channel() {
+ return channel;
+ }
+}
diff --git a/core/src/main/java/com/linecorp/armeria/server/ConnectionLevelSetters.java b/core/src/main/java/com/linecorp/armeria/server/ConnectionLevelSetters.java
new file mode 100644
index 00000000000..2e1ff33c505
--- /dev/null
+++ b/core/src/main/java/com/linecorp/armeria/server/ConnectionLevelSetters.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2025 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you 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:
+ *
+ * https://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 com.linecorp.armeria.server;
+
+import java.util.List;
+
+import com.linecorp.armeria.common.annotation.Nullable;
+import com.linecorp.armeria.common.annotation.UnstableApi;
+
+/**
+ * Defines getters and setters for connection-level server configuration fields.
+ *
+ * Connection-level fields are server-only — they do not span the server/virtual-host merge
+ * boundary, so the intermediate builder value is the resolved value. This interface serves as
+ * a blueprint for which fields have readable intermediate state, enabling
+ * {@link ServerPlugin}s to inspect user-configured values and selectively override them.
+ *
+ * @see ServerBuilder
+ * @see ServerPlugin
+ */
+@UnstableApi
+public interface ConnectionLevelSetters {
+
+ /**
+ * Returns the list of {@link ServerPort}s configured so far.
+ */
+ List ports();
+
+ /**
+ * Adds the specified {@link ServerPort}.
+ */
+ ConnectionLevelSetters port(ServerPort port);
+
+ /**
+ * Returns the {@link ConnectionAcceptor} configured so far, or {@code null} if not set.
+ */
+ @Nullable
+ ConnectionAcceptor connectionAcceptor();
+
+ /**
+ * Sets a {@link ConnectionAcceptor} that is called once per connection to decide
+ * whether to accept the connection.
+ */
+ ConnectionLevelSetters connectionAcceptor(ConnectionAcceptor connectionAcceptor);
+
+ /**
+ * Returns the {@link ServerTlsProvider} configured so far, or {@code null} if not set.
+ */
+ @Nullable
+ ServerTlsProvider tlsProvider();
+
+ /**
+ * Sets the specified {@link ServerTlsProvider} which resolves TLS configuration from a
+ * {@link ConnectionContext}.
+ */
+ ConnectionLevelSetters tlsProvider(ServerTlsProvider serverTlsProvider);
+}
diff --git a/core/src/main/java/com/linecorp/armeria/server/DefaultServerConfig.java b/core/src/main/java/com/linecorp/armeria/server/DefaultServerConfig.java
index 728d98a87d0..bf78bc46bca 100644
--- a/core/src/main/java/com/linecorp/armeria/server/DefaultServerConfig.java
+++ b/core/src/main/java/com/linecorp/armeria/server/DefaultServerConfig.java
@@ -41,13 +41,13 @@
import com.linecorp.armeria.common.annotation.Nullable;
import com.linecorp.armeria.common.util.BlockingTaskExecutor;
import com.linecorp.armeria.common.util.EventLoopGroups;
+import com.linecorp.armeria.internal.common.SslContextFactory;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.binder.jvm.ExecutorServiceMetrics;
import io.netty.channel.ChannelOption;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.EventLoopGroup;
-import io.netty.handler.ssl.SslContext;
import io.netty.util.Mapping;
import io.netty.util.concurrent.FastThreadLocalThread;
import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
@@ -133,9 +133,13 @@ final class DefaultServerConfig implements ServerConfig {
private final List shutdownSupports;
@Nullable
- private final Mapping sslContexts;
+ private final ServerTlsProvider serverTlsProvider;
+ @Nullable
+ private final SslContextFactory sslContextFactory;
private final ServerMetrics serverMetrics;
private final Function super String, ? extends EventLoopGroup> bossGroupFactory;
+ @Nullable
+ private final ConnectionAcceptor connectionAcceptor;
@Nullable
private String strVal;
@@ -163,13 +167,15 @@ final class DefaultServerConfig implements ServerConfig {
Function super ProxiedAddresses, ? extends InetSocketAddress> clientAddressMapper,
boolean enableServerHeader, boolean enableDateHeader,
ServerErrorHandler errorHandler,
- @Nullable Mapping sslContexts,
+ @Nullable ServerTlsProvider serverTlsProvider,
+ @Nullable SslContextFactory sslContextFactory,
Http1HeaderNaming http1HeaderNaming,
DependencyInjector dependencyInjector,
Function super String, String> absoluteUriTransformer,
long unloggedExceptionsReportIntervalMillis,
List shutdownSupports,
- @Nullable Function super String, ? extends EventLoopGroup> bossGroupFactory) {
+ @Nullable Function super String, ? extends EventLoopGroup> bossGroupFactory,
+ @Nullable ConnectionAcceptor connectionAcceptor) {
requireNonNull(ports, "ports");
requireNonNull(defaultVirtualHost, "defaultVirtualHost");
requireNonNull(virtualHosts, "virtualHosts");
@@ -276,7 +282,8 @@ final class DefaultServerConfig implements ServerConfig {
this.enableDateHeader = enableDateHeader;
this.errorHandler = requireNonNull(errorHandler, "errorHandler");
- this.sslContexts = sslContexts;
+ this.serverTlsProvider = serverTlsProvider;
+ this.sslContextFactory = sslContextFactory;
this.http1HeaderNaming = requireNonNull(http1HeaderNaming, "http1HeaderNaming");
this.dependencyInjector = requireNonNull(dependencyInjector, "dependencyInjector");
@SuppressWarnings("unchecked")
@@ -286,6 +293,7 @@ final class DefaultServerConfig implements ServerConfig {
this.unloggedExceptionsReportIntervalMillis = unloggedExceptionsReportIntervalMillis;
this.shutdownSupports = ImmutableList.copyOf(requireNonNull(shutdownSupports, "shutdownSupports"));
this.bossGroupFactory = bossGroupFactory == null ? DEFAULT_BOSS_GROUP_FACTORY : bossGroupFactory;
+ this.connectionAcceptor = connectionAcceptor;
serverMetrics = new ServerMetrics(meterRegistry);
}
@@ -702,11 +710,19 @@ public ServerErrorHandler errorHandler() {
}
/**
- * Returns a map of SslContexts {@link SslContext}.
+ * Returns the {@link ServerTlsProvider}, or {@code null} if not set.
+ */
+ @Nullable
+ ServerTlsProvider serverTlsProvider() {
+ return serverTlsProvider;
+ }
+
+ /**
+ * Returns the {@link SslContextFactory}, or {@code null} if not set.
*/
@Nullable
- Mapping sslContextMapping() {
- return sslContexts;
+ SslContextFactory sslContextFactory() {
+ return sslContextFactory;
}
@Override
@@ -739,6 +755,11 @@ public ServerMetrics serverMetrics() {
return serverMetrics;
}
+ @Nullable
+ ConnectionAcceptor connectionAcceptor() {
+ return connectionAcceptor;
+ }
+
List shutdownSupports() {
return shutdownSupports;
}
diff --git a/core/src/main/java/com/linecorp/armeria/server/FallbackServerTlsProvider.java b/core/src/main/java/com/linecorp/armeria/server/FallbackServerTlsProvider.java
new file mode 100644
index 00000000000..ee984003468
--- /dev/null
+++ b/core/src/main/java/com/linecorp/armeria/server/FallbackServerTlsProvider.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2025 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you 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:
+ *
+ * https://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 com.linecorp.armeria.server;
+
+import java.util.concurrent.CompletableFuture;
+
+import com.linecorp.armeria.common.annotation.Nullable;
+import com.linecorp.armeria.common.util.UnmodifiableFuture;
+
+/**
+ * A {@link ServerTlsProvider} that tries a primary provider first, and if it returns
+ * {@code null}, falls back to a secondary provider.
+ */
+final class FallbackServerTlsProvider implements ServerTlsProvider {
+
+ private final ServerTlsProvider primary;
+ private final ServerTlsProvider fallback;
+
+ FallbackServerTlsProvider(ServerTlsProvider primary, ServerTlsProvider fallback) {
+ this.primary = primary;
+ this.fallback = fallback;
+ }
+
+ @Override
+ public CompletableFuture<@Nullable ServerTlsSpec> serverTlsSpec(ConnectionContext ctx) {
+ return primary.serverTlsSpec(ctx).thenCompose(spec -> {
+ if (spec != null) {
+ return UnmodifiableFuture.completedFuture(spec);
+ }
+ return fallback.serverTlsSpec(ctx);
+ });
+ }
+}
diff --git a/core/src/main/java/com/linecorp/armeria/server/Http2ServerConnectionHandler.java b/core/src/main/java/com/linecorp/armeria/server/Http2ServerConnectionHandler.java
index a27772cfae5..79d56478f9d 100644
--- a/core/src/main/java/com/linecorp/armeria/server/Http2ServerConnectionHandler.java
+++ b/core/src/main/java/com/linecorp/armeria/server/Http2ServerConnectionHandler.java
@@ -66,7 +66,7 @@ private static KeepAliveHandler newKeepAliveHandler(
final long idleTimeoutMillis = cfg.idleTimeoutMillis();
final boolean keepAliveOnPing = cfg.keepAliveOnPing();
final long pingIntervalMillis = cfg.pingIntervalMillis();
- final long maxConnectionAgeMillis = cfg.maxConnectionAgeMillis();
+ final long maxConnectionAgeMillis = resolveMaxConnectionAge(channel, cfg);
final int maxNumRequestsPerConnection = cfg.maxNumRequestsPerConnection();
final boolean needsKeepAliveHandler = needsKeepAliveHandler(
idleTimeoutMillis, pingIntervalMillis, maxConnectionAgeMillis, maxNumRequestsPerConnection);
@@ -80,6 +80,14 @@ private static KeepAliveHandler newKeepAliveHandler(
pingIntervalMillis, maxConnectionAgeMillis, maxNumRequestsPerConnection, keepAliveOnPing);
}
+ private static long resolveMaxConnectionAge(Channel channel, ServerConfig cfg) {
+ final ConnectionContext connCtx = ConnectionContext.get(channel);
+ if (connCtx != null && connCtx.maxConnectionAgeMillis() > 0) {
+ return connCtx.maxConnectionAgeMillis();
+ }
+ return cfg.maxConnectionAgeMillis();
+ }
+
ServerHttp2ObjectEncoder getOrCreateResponseEncoder(ChannelHandlerContext connectionHandlerCtx) {
if (responseEncoder == null) {
assert connectionHandlerCtx.handler() == this;
diff --git a/core/src/main/java/com/linecorp/armeria/server/HttpServerHandler.java b/core/src/main/java/com/linecorp/armeria/server/HttpServerHandler.java
index adbc7e29727..ea4c00e7cc6 100644
--- a/core/src/main/java/com/linecorp/armeria/server/HttpServerHandler.java
+++ b/core/src/main/java/com/linecorp/armeria/server/HttpServerHandler.java
@@ -194,8 +194,7 @@ static void safeClose(Channel ch) {
@Nullable
private ServerHttpObjectEncoder responseEncoder;
- @Nullable
- private final ProxiedAddresses proxiedAddresses;
+ private final ConnectionContext connectionContext;
private final InetSocketAddress remoteAddress;
private final InetSocketAddress localAddress;
@@ -209,7 +208,7 @@ static void safeClose(Channel ch) {
Channel channel, GracefulShutdownSupport gracefulShutdownSupport,
@Nullable ServerHttpObjectEncoder responseEncoder,
SessionProtocol protocol,
- @Nullable ProxiedAddresses proxiedAddresses) {
+ ConnectionContext connectionContext) {
assert protocol == H1 || protocol == H1C || protocol == H2;
@@ -217,13 +216,13 @@ static void safeClose(Channel ch) {
final ServerPortMetric serverPortMetric = channel.attr(SERVER_PORT_METRIC).get();
assert serverPortMetric != null;
this.serverPortMetric = serverPortMetric;
+ this.connectionContext = requireNonNull(connectionContext, "connectionContext");
remoteAddress = firstNonNull(ChannelUtil.remoteAddress(channel), UNKNOWN_ADDR);
localAddress = firstNonNull(ChannelUtil.localAddress(channel), UNKNOWN_ADDR);
this.gracefulShutdownSupport = requireNonNull(gracefulShutdownSupport, "gracefulShutdownSupport");
this.protocol = requireNonNull(protocol, "protocol");
this.responseEncoder = responseEncoder;
- this.proxiedAddresses = proxiedAddresses;
unfinishedRequests = new IdentityHashMap<>();
}
@@ -413,8 +412,9 @@ private void handleRequest(ChannelHandlerContext ctx, DecodedHttpRequest req) th
// `determineProxiedAddresses` could throw IllegalArgumentException if the headers are invalid.
// If it does, we will return a 400 Bad Request response.
// We need to get the ServiceConfig before responding, hence, we store the exception first.
- ProxiedAddresses proxiedAddresses = (this.proxiedAddresses != null) ?
- this.proxiedAddresses : ProxiedAddresses.of(remoteAddress);
+ final ProxiedAddresses connProxiedAddresses = connectionContext.proxiedAddresses();
+ ProxiedAddresses proxiedAddresses = (connProxiedAddresses != null) ?
+ connProxiedAddresses : ProxiedAddresses.of(remoteAddress);
IllegalArgumentException invalidProxiedAddressesException = null;
try {
proxiedAddresses = determineProxiedAddresses(headers);
@@ -465,6 +465,7 @@ private void handleRequest(ChannelHandlerContext ctx, DecodedHttpRequest req) th
serviceCfg, channel, serviceEventLoop, config.meterRegistry(), protocol,
nextRequestId(routingCtx, serviceCfg), routingCtx, routingResult, req.exchangeType(),
req, sslSession, proxiedAddresses, clientAddress, remoteAddress, localAddress,
+ connectionContext,
req.requestStartTimeNanos(), req.requestStartTimeMicros(), serviceCfg.contextHook());
HttpResponse res;
@@ -592,6 +593,7 @@ private HttpResponse serveInServiceEventLoop(DecodedHttpRequest req,
}
private ProxiedAddresses determineProxiedAddresses(RequestHeaders headers) {
+ final ProxiedAddresses proxiedAddresses = connectionContext.proxiedAddresses();
if (config.clientAddressTrustedProxyFilter().test(remoteAddress.getAddress())) {
return HttpHeaderUtil.determineProxiedAddresses(
headers, config.clientAddressSources(), proxiedAddresses,
@@ -740,6 +742,7 @@ private ServiceRequestContext newEarlyRespondingRequestContext(Channel channel,
channel, eventLoop, NoopMeterRegistry.get(), protocol(),
nextRequestId(routingCtx, serviceConfig), routingCtx, routingResult, req.exchangeType(),
req, sslSession, proxiedAddresses, clientAddress, remoteAddress, localAddress,
+ connectionContext,
System.nanoTime(), SystemInfo.currentTimeMicros(), NOOP_CONTEXT_HOOK);
}
diff --git a/core/src/main/java/com/linecorp/armeria/server/HttpServerPipelineConfigurator.java b/core/src/main/java/com/linecorp/armeria/server/HttpServerPipelineConfigurator.java
index 20ae3ddf2a9..92aae41a0d6 100644
--- a/core/src/main/java/com/linecorp/armeria/server/HttpServerPipelineConfigurator.java
+++ b/core/src/main/java/com/linecorp/armeria/server/HttpServerPipelineConfigurator.java
@@ -54,6 +54,7 @@
import com.linecorp.armeria.internal.common.KeepAliveHandler;
import com.linecorp.armeria.internal.common.NoopKeepAliveHandler;
import com.linecorp.armeria.internal.common.ReadSuppressingHandler;
+import com.linecorp.armeria.internal.common.SslContextFactory;
import com.linecorp.armeria.internal.common.TrafficLoggingHandler;
import com.linecorp.armeria.internal.common.util.CertificateUtil;
import com.linecorp.armeria.internal.common.util.ChannelUtil;
@@ -96,11 +97,8 @@
import io.netty.handler.logging.LogLevel;
import io.netty.handler.ssl.ApplicationProtocolNames;
import io.netty.handler.ssl.ApplicationProtocolNegotiationHandler;
-import io.netty.handler.ssl.SniHandler;
-import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslHandler;
import io.netty.util.AsciiString;
-import io.netty.util.Mapping;
import io.netty.util.NetUtil;
import io.netty.util.concurrent.ScheduledFuture;
@@ -159,6 +157,7 @@ protected void initChannel(Channel ch) throws Exception {
final ChannelPipeline p = ch.pipeline();
p.addLast(new FlushConsolidationHandler());
p.addLast(ReadSuppressingHandler.INSTANCE);
+
configurePipeline(p, port.protocols(), null);
config.childChannelPipelineCustomizer().accept(p);
}
@@ -198,6 +197,26 @@ private void configurePipeline(ChannelPipeline p, Set protocols
}
private void configureHttp(ChannelPipeline p, @Nullable ProxiedAddresses proxiedAddresses) {
+ final Channel ch = p.channel();
+ final ConnectionContext connectionContext =
+ new ConnectionContext(H1C, "", null, proxiedAddresses, ch);
+
+ // Call the ConnectionAcceptor for non-TLS connections if present.
+ final ConnectionAcceptor acceptor = config.connectionAcceptor();
+ if (acceptor != null) {
+ acceptor.accept(connectionContext).whenCompleteAsync((accepted, t) -> {
+ if (t != null || Boolean.FALSE.equals(accepted)) {
+ ch.close();
+ } else {
+ finishConfigureHttp(p, connectionContext);
+ }
+ }, ch.eventLoop());
+ } else {
+ finishConfigureHttp(p, connectionContext);
+ }
+ }
+
+ private void finishConfigureHttp(ChannelPipeline p, ConnectionContext connectionContext) {
final long idleTimeoutMillis = config.idleTimeoutMillis();
final long maxConnectionAgeMillis = config.maxConnectionAgeMillis();
final int maxNumRequestsPerConnection = config.maxNumRequestsPerConnection();
@@ -221,7 +240,7 @@ private void configureHttp(ChannelPipeline p, @Nullable ProxiedAddresses proxied
final HttpServerHandler httpServerHandler = new HttpServerHandler(config, p.channel(),
gracefulShutdownSupport,
responseEncoder,
- H1C, proxiedAddresses);
+ H1C, connectionContext);
p.addLast(new Http2PrefaceOrHttpHandler(responseEncoder, httpServerHandler));
p.addLast(httpServerHandler);
}
@@ -231,26 +250,27 @@ private Timer newKeepAliveTimer(SessionProtocol protocol) {
ImmutableList.of(Tag.of("protocol", protocol.uriText())));
}
- private void configureHttps(ChannelPipeline p, @Nullable ProxiedAddresses proxiedAddresses) {
- p.addLast(newSniHandler(p));
- p.addLast(TrafficLoggingHandler.SERVER);
- p.addLast(new Http2OrHttpHandler(proxiedAddresses));
+ private long resolveMaxConnectionAge(Channel ch) {
+ final ConnectionContext connCtx = ConnectionContext.get(ch);
+ if (connCtx != null && connCtx.maxConnectionAgeMillis() > 0) {
+ return connCtx.maxConnectionAgeMillis();
+ }
+ return config.maxConnectionAgeMillis();
}
- private SniHandler newSniHandler(ChannelPipeline p) {
- final Mapping sslContexts =
- requireNonNull(config.sslContextMapping(), "config.sslContextMapping() returned null");
- final SniHandler sniHandler = new SniHandler(sslContexts, Flags.defaultMaxClientHelloLength(),
- config.idleTimeoutMillis());
- if (sslContexts instanceof TlsProviderMapping) {
- p.channel().closeFuture().addListener(future -> {
- final SslContext sslContext = sniHandler.sslContext();
- if (sslContext != null) {
- ((TlsProviderMapping) sslContexts).release(sslContext);
- }
- });
- }
- return sniHandler;
+ private void configureHttps(ChannelPipeline p, @Nullable ProxiedAddresses proxiedAddresses) {
+ final ServerTlsProvider serverTlsProvider = config.serverTlsProvider();
+ final SslContextFactory sslContextFactory = config.sslContextFactory();
+ final ConnectionAcceptor acceptor = config.connectionAcceptor();
+ assert serverTlsProvider != null : "HTTPS configured but no ServerTlsProvider";
+ assert sslContextFactory != null : "HTTPS configured but no SslContextFactory";
+
+ p.addLast(new ConnectionAcceptHandler(acceptor, serverTlsProvider, sslContextFactory,
+ proxiedAddresses,
+ Flags.defaultMaxClientHelloLength(),
+ config.idleTimeoutMillis()));
+ p.addLast(TrafficLoggingHandler.SERVER);
+ p.addLast(new Http2OrHttpHandler());
}
private Http2ConnectionHandler newHttp2ConnectionHandler(ChannelPipeline pipeline, AsciiString scheme) {
@@ -486,14 +506,11 @@ private final class Http2OrHttpHandler extends ApplicationProtocolNegotiationHan
*/
private static final String DUMMY_CIPHER_SUITE = "SSL_NULL_WITH_NULL_NULL";
- @Nullable
- private final ProxiedAddresses proxiedAddresses;
private boolean loggedHandshakeFailure;
private boolean addedExceptionLogger;
- Http2OrHttpHandler(@Nullable ProxiedAddresses proxiedAddresses) {
+ Http2OrHttpHandler() {
super(ApplicationProtocolNames.HTTP_1_1);
- this.proxiedAddresses = proxiedAddresses;
}
@Override
@@ -513,19 +530,27 @@ protected void configurePipeline(ChannelHandlerContext ctx, String protocol) thr
throw new SSLHandshakeException("unsupported application protocol: " + protocol);
}
+ private ConnectionContext connectionContext(Channel ch) {
+ final ConnectionContext connectionContext = ConnectionContext.get(ch);
+ assert connectionContext != null : "ConnectionContext must exist for HTTPS";
+ return connectionContext;
+ }
+
private void addHttp2Handlers(ChannelHandlerContext ctx) {
final ChannelPipeline p = ctx.pipeline();
+ final Channel ch = p.channel();
p.addLast(newHttp2ConnectionHandler(p, SCHEME_HTTPS));
- p.addLast(new HttpServerHandler(config, p.channel(),
+ p.addLast(new HttpServerHandler(config, ch,
gracefulShutdownSupport,
- null, H2, proxiedAddresses));
+ null, H2,
+ connectionContext(ch)));
}
private void addHttpHandlers(ChannelHandlerContext ctx) {
final Channel ch = ctx.channel();
final ChannelPipeline p = ctx.pipeline();
final long idleTimeoutMillis = config.idleTimeoutMillis();
- final long maxConnectionAgeMillis = config.maxConnectionAgeMillis();
+ final long maxConnectionAgeMillis = resolveMaxConnectionAge(ch);
final int maxNumRequestsPerConnection = config.maxNumRequestsPerConnection();
final boolean needsKeepAliveHandler =
needsKeepAliveHandler(idleTimeoutMillis, /* pingIntervalMillis */ 0,
@@ -548,7 +573,8 @@ private void addHttpHandlers(ChannelHandlerContext ctx) {
config.http1MaxChunkSize()));
final HttpServerHandler httpServerHandler = new HttpServerHandler(config, ch,
gracefulShutdownSupport,
- encoder, H1, proxiedAddresses);
+ encoder, H1,
+ connectionContext(ch));
p.addLast(new Http1RequestDecoder(config, ch, SCHEME_HTTPS, encoder, httpServerHandler));
p.addLast(httpServerHandler);
}
diff --git a/core/src/main/java/com/linecorp/armeria/server/Server.java b/core/src/main/java/com/linecorp/armeria/server/Server.java
index 902111e0088..96a83163c54 100644
--- a/core/src/main/java/com/linecorp/armeria/server/Server.java
+++ b/core/src/main/java/com/linecorp/armeria/server/Server.java
@@ -113,6 +113,7 @@ public static ServerBuilder builder() {
@GuardedBy("lock")
private final Map activePorts = new LinkedHashMap<>();
private final ConnectionLimitingHandler connectionLimitingHandler;
+ private final List plugins;
private boolean hasWebSocketService;
@Nullable
@@ -120,10 +121,15 @@ public static ServerBuilder builder() {
ServerBootstrap serverBootstrap;
Server(DefaultServerConfig serverConfig) {
+ this(serverConfig, ImmutableList.of());
+ }
+
+ Server(DefaultServerConfig serverConfig, List plugins) {
serverConfig.setServer(this);
config = new UpdatableServerConfig(requireNonNull(serverConfig, "serverConfig"));
startStop = new ServerStartStopSupport(config.startStopExecutor());
connectionLimitingHandler = new ConnectionLimitingHandler(config.maxNumConnections());
+ this.plugins = requireNonNull(plugins, "plugins");
// Server-wide metrics.
RequestTargetCache.registerServerMetrics(config.meterRegistry());
@@ -418,6 +424,7 @@ public void reconfigure(ServerConfigurator serverConfigurator) {
requireNonNull(serverConfigurator, "serverConfigurator");
final ServerBuilder sb = builder();
serverConfigurator.reconfigure(sb);
+ plugins.forEach(plugin -> plugin.install(sb));
final ImmutableList serverPorts;
lock.lock();
try {
@@ -719,6 +726,15 @@ private CompletableFuture shutdownServerHandlers() {
private void finishDoStop(CompletableFuture future) {
serverChannels.clear();
+ // Close plugins first so they can clean up their resources.
+ for (ServerPlugin plugin : plugins) {
+ try {
+ plugin.close();
+ } catch (Exception e) {
+ logger.warn("Failed to close plugin: {}", plugin, e);
+ }
+ }
+
final Builder builder = ImmutableList.builder();
builder.addAll(config.delegate().shutdownSupports());
for (VirtualHost virtualHost : config.virtualHosts()) {
diff --git a/core/src/main/java/com/linecorp/armeria/server/ServerBuilder.java b/core/src/main/java/com/linecorp/armeria/server/ServerBuilder.java
index 11dd10932f3..305a42f86d8 100644
--- a/core/src/main/java/com/linecorp/armeria/server/ServerBuilder.java
+++ b/core/src/main/java/com/linecorp/armeria/server/ServerBuilder.java
@@ -90,7 +90,6 @@
import com.linecorp.armeria.common.annotation.Nullable;
import com.linecorp.armeria.common.annotation.UnstableApi;
import com.linecorp.armeria.common.logging.RequestOnlyLog;
-import com.linecorp.armeria.common.metric.MeterIdPrefix;
import com.linecorp.armeria.common.util.BlockingTaskExecutor;
import com.linecorp.armeria.common.util.DomainSocketAddress;
import com.linecorp.armeria.common.util.EventLoopGroups;
@@ -118,7 +117,6 @@
import io.netty.handler.codec.http2.DefaultHttp2LocalFlowController;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;
-import io.netty.util.Mapping;
import io.netty.util.NetUtil;
import it.unimi.dsi.fastutil.objects.Object2ObjectArrayMap;
@@ -171,7 +169,8 @@
*
* @see VirtualHostBuilder
*/
-public final class ServerBuilder implements TlsSetters, ServiceConfigsBuilder {
+public final class ServerBuilder implements ConnectionLevelSetters, TlsSetters,
+ ServiceConfigsBuilder {
private static final Logger logger = LoggerFactory.getLogger(ServerBuilder.class);
// Defaults to no graceful shutdown.
@@ -243,11 +242,11 @@ public final class ServerBuilder implements TlsSetters, ServiceConfigsBuilder shutdownSupports = new ArrayList<>();
private int http2MaxResetFramesPerWindow = Flags.defaultServerHttp2MaxResetFramesPerMinute();
private int http2MaxResetFramesWindowSeconds = 60;
- @Nullable
- private TlsProvider tlsProvider;
- @Nullable
- private ServerTlsConfig tlsConfig;
+ private final ServerTlsProviderBuilder serverTlsProviderBuilder = new ServerTlsProviderBuilder();
private Function super String, ? extends EventLoopGroup> bossGroupFactory = DEFAULT_BOSS_GROUP_FACTORY;
+ @Nullable
+ private ConnectionAcceptor connectionAcceptor;
+ private final List plugins = new ArrayList<>();
ServerBuilder() {
// Set the default host-level properties.
@@ -342,6 +341,14 @@ public ServerBuilder https(InetSocketAddress localAddress) {
return port(new ServerPort(requireNonNull(localAddress, "localAddress"), HTTPS));
}
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public List ports() {
+ return Collections.unmodifiableList(ports);
+ }
+
/**
* Adds a new {@link ServerPort} that listens to the specified {@code port} of all available network
* interfaces using the specified {@link SessionProtocol}s. Specify multiple protocols to serve more than
@@ -429,6 +436,7 @@ public ServerBuilder port(InetSocketAddress localAddress, IterableWhat happens if no HTTP(S) port is specified?
*/
+ @Override
public ServerBuilder port(ServerPort port) {
ports.add(requireNonNull(port, "port"));
return this;
@@ -525,6 +533,54 @@ public ServerBuilder childChannelPipelineCustomizer(
return this;
}
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ @Nullable
+ public ConnectionAcceptor connectionAcceptor() {
+ return connectionAcceptor;
+ }
+
+ /**
+ * Sets the {@link ConnectionAcceptor} that is called once per connection, before TLS
+ * negotiation, to decide whether to accept the connection. The acceptor can also store
+ * resolved policy on the {@link ConnectionContext} for use by {@link ServerTlsProvider}
+ * and service decorators.
+ *
+ * Calling this method replaces any previously set {@link ConnectionAcceptor}.
+ * To compose with an existing acceptor, read it first via {@link #connectionAcceptor()}
+ * and delegate to it from your new acceptor.
+ *
+ *
When a {@link ConnectionAcceptor} is set, all HTTPS connections go through
+ * full ClientHello parsing regardless of the {@link ServerTlsProvider} type.
+ */
+ @UnstableApi
+ @Override
+ public ServerBuilder connectionAcceptor(ConnectionAcceptor connectionAcceptor) {
+ this.connectionAcceptor = requireNonNull(connectionAcceptor, "connectionAcceptor");
+ return this;
+ }
+
+ /**
+ * Adds a {@link ServerPlugin} that will be installed during {@link Server} construction
+ * and during {@link Server#reconfigure(ServerConfigurator)}.
+ *
+ *
Plugins are installed in insertion order. Each plugin's
+ * {@link ServerPlugin#install(ServerBuilder)} is called before the server configuration
+ * is built. Plugins are closed when the {@link Server} stops.
+ *
+ *
Note: Plugins can only be added at initial build time. Calling
+ * {@link ServerBuilder#addPlugin(ServerPlugin)} inside a {@link ServerConfigurator} passed to
+ * {@link Server#reconfigure(ServerConfigurator)} has no effect — the server uses the plugins
+ * registered at construction. Existing plugins are re-installed automatically during reconfiguration.
+ */
+ @UnstableApi
+ public ServerBuilder addPlugin(ServerPlugin plugin) {
+ plugins.add(requireNonNull(plugin, "plugin"));
+ return this;
+ }
+
/**
* Sets the worker {@link EventLoopGroup} which is responsible for performing socket I/O and running
* {@link Service#serve(ServiceRequestContext, Request)}.
@@ -1228,16 +1284,23 @@ public ServerBuilder tls(KeyManagerFactory keyManagerFactory) {
*
*
Note that this method mutually exclusive with {@link #tls(TlsKeyPair)} and other static TLS settings.
*/
+ /**
+ * {@inheritDoc}
+ */
@UnstableApi
- public ServerBuilder tlsProvider(TlsProvider tlsProvider) {
- requireNonNull(tlsProvider, "tlsProvider");
- this.tlsProvider = tlsProvider;
- tlsConfig = null;
+ @Override
+ @Nullable
+ public ServerTlsProvider tlsProvider() {
+ return serverTlsProviderBuilder.serverTlsProvider();
+ }
- if (tlsProvider.autoClose()) {
- shutdownSupports.add(ShutdownSupport.of(tlsProvider));
- }
- return this;
+ /**
+ * Sets the specified {@link TlsProvider} which will be used for building an {@link SslContext} of
+ * a hostname. A default {@link ServerTlsConfig} is used.
+ */
+ @UnstableApi
+ public ServerBuilder tlsProvider(TlsProvider tlsProvider) {
+ return tlsProvider(tlsProvider, ServerTlsConfig.builder().build());
}
/**
@@ -1268,8 +1331,9 @@ public ServerBuilder tlsProvider(TlsProvider tlsProvider) {
*/
@UnstableApi
public ServerBuilder tlsProvider(TlsProvider tlsProvider, ServerTlsConfig tlsConfig) {
- tlsProvider(tlsProvider);
- this.tlsConfig = requireNonNull(tlsConfig, "tlsConfig");
+ requireNonNull(tlsProvider, "tlsProvider");
+ requireNonNull(tlsConfig, "tlsConfig");
+ serverTlsProviderBuilder.setTlsProvider(tlsProvider, tlsConfig);
if (tlsProvider.autoClose()) {
shutdownSupports.add(ShutdownSupport.of(tlsProvider));
@@ -1277,6 +1341,24 @@ public ServerBuilder tlsProvider(TlsProvider tlsProvider, ServerTlsConfig tlsCon
return this;
}
+ /**
+ * Sets the {@link ServerTlsProvider} which resolves TLS configuration from a
+ * {@link ConnectionContext}. This is used for advanced TLS resolution that depends on
+ * connection-level properties beyond hostname, such as xDS filter chain matching.
+ *
+ *
Calling this method replaces any previously set {@link ServerTlsProvider}.
+ * If the provider returns {@code null}, the fallback TLS configuration from
+ * {@link #tls(TlsKeyPair)}, {@link #tlsProvider(TlsProvider)}, or VirtualHost TLS settings
+ * is used.
+ */
+ @UnstableApi
+ @Override
+ public ServerBuilder tlsProvider(ServerTlsProvider serverTlsProvider) {
+ requireNonNull(serverTlsProvider, "serverTlsProvider");
+ serverTlsProviderBuilder.setServerTlsProvider(serverTlsProvider);
+ return this;
+ }
+
/**
* Configures SSL or TLS of the {@link Server} with an auto-generated self-signed certificate.
*
@@ -2438,7 +2520,8 @@ public ServerBuilder unloggedExceptionsReportIntervalMillis(long unloggedExcepti
* Returns a newly-created {@link Server} based on the configuration properties set so far.
*/
public Server build() {
- final Server server = new Server(buildServerConfig(ports));
+ plugins.forEach(plugin -> plugin.install(this));
+ final Server server = new Server(buildServerConfig(ports), ImmutableList.copyOf(plugins));
serverListeners.forEach(server::addListener);
return server;
}
@@ -2458,29 +2541,20 @@ DefaultServerConfig buildServerConfig(List serverPorts) {
unloggedExceptionsReporter = null;
}
- final TlsProvider tlsProvider = this.tlsProvider;
- if (tlsProvider != null && tlsProvider.autoClose()) {
- shutdownSupports.add(ShutdownSupport.of(tlsProvider));
- }
-
final ServerErrorHandler errorHandler = ServerErrorHandlerDecorators.decorate(
this.errorHandler == null ? ServerErrorHandler.ofDefault()
: this.errorHandler.orElse(ServerErrorHandler.ofDefault()));
- final MeterIdPrefix meterIdPrefix = tlsConfig != null ? tlsConfig.meterIdPrefix() : null;
- final SslContextFactory sslContextFactory = new SslContextFactory(meterIdPrefix, meterRegistry);
+ final SslContextFactory sslContextFactory = new SslContextFactory(null, meterRegistry);
final VirtualHost defaultVirtualHost =
defaultVirtualHostBuilder.build(virtualHostTemplate, dependencyInjector,
unloggedExceptionsReporter, errorHandler,
- tlsProvider, sslContextFactory);
+ sslContextFactory);
final List virtualHosts =
virtualHostBuilders.stream()
.map(vhb -> vhb.build(virtualHostTemplate, dependencyInjector,
unloggedExceptionsReporter, errorHandler,
- tlsProvider, sslContextFactory))
+ sslContextFactory))
.collect(toImmutableList());
- // Pre-populate the domain name mapping for later matching.
- final Mapping sslContexts;
- final SslContext defaultSslContext = findDefaultSslContext(defaultVirtualHost, virtualHosts);
final Collection ports;
for (ServerPort port : this.ports) {
@@ -2515,10 +2589,14 @@ DefaultServerConfig buildServerConfig(List serverPorts) {
}
}
- checkState(defaultSslContext == null || tlsProvider == null,
- "Can't set %s with a static TLS setting", TlsProvider.class.getSimpleName());
- if (defaultSslContext == null && tlsProvider == null) {
- sslContexts = null;
+ // Build the ServerTlsProvider — handles all cases:
+ // 1) Direct ServerTlsProvider (e.g. xDS)
+ // 2) TlsProvider wrapped in TlsProviderAdapter
+ // 3) Static VirtualHost-based TLS via StaticTlsProvider
+ final ServerTlsProvider serverTlsProvider =
+ serverTlsProviderBuilder.build(defaultVirtualHost, virtualHosts);
+
+ if (serverTlsProvider == null) {
if (!serverPorts.isEmpty()) {
ports = resolveDistinctPorts(serverPorts);
for (final ServerPort p : ports) {
@@ -2544,28 +2622,6 @@ DefaultServerConfig buildServerConfig(List serverPorts) {
} else {
ports = ImmutableList.of(new ServerPort(0, HTTPS));
}
-
- if (defaultSslContext != null) {
- final DomainMappingBuilder
- mappingBuilder = new DomainMappingBuilder<>(defaultSslContext);
- for (VirtualHost h : virtualHosts) {
- final SslContext sslCtx = h.sslContext();
- if (sslCtx != null) {
- final String originalHostnamePattern = h.originalHostnamePattern();
- // The SslContext for the default virtual host was added when creating
- // DomainMappingBuilder.
- if (!"*".equals(originalHostnamePattern)) {
- mappingBuilder.add(originalHostnamePattern, sslCtx);
- }
- }
- }
- sslContexts = mappingBuilder.build();
- } else {
- final TlsEngineType tlsEngineType = defaultVirtualHost.tlsEngineType();
- assert tlsEngineType != null;
- assert tlsProvider != null;
- sslContexts = new TlsProviderMapping(tlsProvider, tlsEngineType, tlsConfig, sslContextFactory);
- }
}
if (pingIntervalMillis > 0) {
pingIntervalMillis = Math.max(pingIntervalMillis, MIN_PING_INTERVAL_MILLIS);
@@ -2586,7 +2642,7 @@ DefaultServerConfig buildServerConfig(List serverPorts) {
final BlockingTaskExecutor blockingTaskExecutor = defaultVirtualHost.blockingTaskExecutor();
return new DefaultServerConfig(
- ports, setSslContextIfAbsent(defaultVirtualHost, defaultSslContext),
+ ports, defaultVirtualHost,
virtualHosts, workerGroup, shutdownWorkerGroupOnStop, startStopExecutor, maxNumConnections,
idleTimeoutMillis, keepAliveOnPing, pingIntervalMillis, maxConnectionAgeMillis,
maxNumRequestsPerConnection,
@@ -2600,10 +2656,11 @@ ports, setSslContextIfAbsent(defaultVirtualHost, defaultSslContext),
meterRegistry, proxyProtocolMaxTlvSize, channelOptions, newChildChannelOptions,
childChannelPipelineCustomizer,
clientAddressSources, clientAddressTrustedProxyFilter, clientAddressFilter, clientAddressMapper,
- enableServerHeader, enableDateHeader, errorHandler, sslContexts,
+ enableServerHeader, enableDateHeader, errorHandler,
+ serverTlsProvider, sslContextFactory,
http1HeaderNaming, dependencyInjector, absoluteUriTransformer,
unloggedExceptionsReportIntervalMillis, ImmutableList.copyOf(shutdownSupports),
- bossGroupFactory);
+ bossGroupFactory, connectionAcceptor);
}
/**
@@ -2665,31 +2722,6 @@ private DependencyInjector dependencyInjectorOrReflective() {
return reflectiveDependencyInjector;
}
- private static VirtualHost setSslContextIfAbsent(VirtualHost h,
- @Nullable SslContext defaultSslContext) {
- if (h.sslContext() != null || defaultSslContext == null) {
- return h;
- }
- return h.withNewSslContext(defaultSslContext);
- }
-
- @Nullable
- private static SslContext findDefaultSslContext(VirtualHost defaultVirtualHost,
- List virtualHosts) {
- final SslContext defaultSslContext = defaultVirtualHost.sslContext();
- if (defaultSslContext != null) {
- return defaultSslContext;
- }
-
- for (int i = virtualHosts.size() - 1; i >= 0; i--) {
- final SslContext sslContext = virtualHosts.get(i).sslContext();
- if (sslContext != null) {
- return sslContext;
- }
- }
- return null;
- }
-
private static void warnIfServiceHasMultipleRoutes(String path, HttpService service) {
if (service instanceof ServiceWithRoutes) {
if (!Flags.reportMaskedRoutes()) {
@@ -2705,4 +2737,55 @@ private static void warnIfServiceHasMultipleRoutes(String path, HttpService serv
}
}
}
+
+ /**
+ * Holds configuration for building a {@link ServerTlsProvider}.
+ *
+ * {@link TlsProvider} and {@link ServerTlsProvider} are mutually exclusive — setting
+ * one replaces the other, just like {@code tls()} and {@code tlsProvider(TlsProvider)}
+ * are mutually exclusive on main. At build time, exactly one source is used:
+ *
+ * - {@link ServerTlsProvider} if set via {@code tlsProvider(ServerTlsProvider)}
+ * - {@link TlsProvider} (wrapped in {@link TlsProviderAdapter}) if set via
+ * {@code tlsProvider(TlsProvider)}
+ * - {@link StaticTlsProvider} from VirtualHost {@code tls()} settings otherwise
+ *
+ */
+ private static final class ServerTlsProviderBuilder {
+ @Nullable
+ private ServerTlsProvider serverTlsProvider;
+
+ void setTlsProvider(TlsProvider tlsProvider, ServerTlsConfig tlsConfig) {
+ serverTlsProvider = new TlsProviderAdapter(tlsProvider, tlsConfig);
+ }
+
+ void setServerTlsProvider(ServerTlsProvider serverTlsProvider) {
+ this.serverTlsProvider = serverTlsProvider;
+ }
+
+ @Nullable
+ ServerTlsProvider serverTlsProvider() {
+ return serverTlsProvider;
+ }
+
+ /**
+ * Builds the {@link ServerTlsProvider}.
+ *
+ * {@code tlsProvider(ServerTlsProvider)} can coexist with {@code tls()} —
+ * the {@link ServerTlsProvider} is tried first, and if it returns {@code null},
+ * the static VirtualHost TLS is used as fallback.
+ */
+ @Nullable
+ ServerTlsProvider build(VirtualHost defaultVirtualHost, List virtualHosts) {
+ if (serverTlsProvider != null) {
+ final StaticTlsProvider fallback =
+ StaticTlsProvider.of(defaultVirtualHost, virtualHosts);
+ if (fallback == null) {
+ return serverTlsProvider;
+ }
+ return new FallbackServerTlsProvider(serverTlsProvider, fallback);
+ }
+ return StaticTlsProvider.of(defaultVirtualHost, virtualHosts);
+ }
+ }
}
diff --git a/core/src/main/java/com/linecorp/armeria/server/ServerPlugin.java b/core/src/main/java/com/linecorp/armeria/server/ServerPlugin.java
new file mode 100644
index 00000000000..9527dcad353
--- /dev/null
+++ b/core/src/main/java/com/linecorp/armeria/server/ServerPlugin.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2025 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you 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:
+ *
+ * https://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 com.linecorp.armeria.server;
+
+import com.linecorp.armeria.common.annotation.UnstableApi;
+import com.linecorp.armeria.common.util.SafeCloseable;
+
+/**
+ * A plugin that encapsulates multi-concern registration into a single
+ * {@link ServerBuilder#addPlugin(ServerPlugin)} call.
+ *
+ * The {@link #install(ServerBuilder)} method is called during {@link Server} construction
+ * and during {@link Server#reconfigure(ServerConfigurator)}, allowing the plugin to register
+ * any combination of server-level concerns (e.g., connection decorators, server listeners,
+ * service decorators).
+ *
+ *
The {@link #close()} method is called when the {@link Server} stops, allowing the plugin
+ * to clean up resources such as subscriptions or background tasks.
+ */
+@UnstableApi
+public interface ServerPlugin extends SafeCloseable {
+
+ /**
+ * Installs this plugin into the given {@link ServerBuilder}. Called during
+ * {@link Server} construction and during {@link Server#reconfigure(ServerConfigurator)}.
+ *
+ *
Implementations may call any {@link ServerBuilder} method, such as
+ * {@link ServerBuilder#port(int, SessionProtocol...)},
+ * {@link ServerBuilder#tlsProvider(com.linecorp.armeria.common.TlsProvider)},
+ * {@link ServerBuilder#serverListener(ServerListener)}, or
+ * {@link ServerBuilder#decorator(java.util.function.Function)}.
+ */
+ void install(ServerBuilder sb);
+}
diff --git a/core/src/main/java/com/linecorp/armeria/server/ServerTlsConfig.java b/core/src/main/java/com/linecorp/armeria/server/ServerTlsConfig.java
index d1c63db70e8..30bfbaaf8ed 100644
--- a/core/src/main/java/com/linecorp/armeria/server/ServerTlsConfig.java
+++ b/core/src/main/java/com/linecorp/armeria/server/ServerTlsConfig.java
@@ -25,6 +25,7 @@
import com.linecorp.armeria.common.annotation.Nullable;
import com.linecorp.armeria.common.annotation.UnstableApi;
import com.linecorp.armeria.common.metric.MeterIdPrefix;
+import com.linecorp.armeria.common.util.TlsEngineType;
import io.netty.handler.ssl.ClientAuth;
import io.netty.handler.ssl.SslContextBuilder;
@@ -43,11 +44,14 @@ public static ServerTlsConfigBuilder builder() {
}
private final ClientAuth clientAuth;
+ private final TlsEngineType tlsEngineType;
ServerTlsConfig(boolean allowsUnsafeCiphers, @Nullable MeterIdPrefix meterIdPrefix,
- ClientAuth clientAuth, Consumer tlsCustomizer) {
+ ClientAuth clientAuth, Consumer tlsCustomizer,
+ TlsEngineType tlsEngineType) {
super(allowsUnsafeCiphers, meterIdPrefix, tlsCustomizer);
this.clientAuth = clientAuth;
+ this.tlsEngineType = tlsEngineType;
}
/**
@@ -57,6 +61,13 @@ public ClientAuth clientAuth() {
return clientAuth;
}
+ /**
+ * Returns the {@link TlsEngineType} to use for TLS.
+ */
+ public TlsEngineType tlsEngineType() {
+ return tlsEngineType;
+ }
+
@Override
public String toString() {
return MoreObjects.toStringHelper(this)
@@ -65,6 +76,7 @@ public String toString() {
.add("meterIdPrefix", meterIdPrefix())
.add("clientAuth", clientAuth)
.add("tlsCustomizer", tlsCustomizer())
+ .add("tlsEngineType", tlsEngineType)
.toString();
}
}
diff --git a/core/src/main/java/com/linecorp/armeria/server/ServerTlsConfigBuilder.java b/core/src/main/java/com/linecorp/armeria/server/ServerTlsConfigBuilder.java
index 97c53e828aa..2fc5ab0fef9 100644
--- a/core/src/main/java/com/linecorp/armeria/server/ServerTlsConfigBuilder.java
+++ b/core/src/main/java/com/linecorp/armeria/server/ServerTlsConfigBuilder.java
@@ -19,8 +19,10 @@
import static java.util.Objects.requireNonNull;
import com.linecorp.armeria.common.AbstractTlsConfigBuilder;
+import com.linecorp.armeria.common.Flags;
import com.linecorp.armeria.common.TlsProvider;
import com.linecorp.armeria.common.annotation.UnstableApi;
+import com.linecorp.armeria.common.util.TlsEngineType;
import io.netty.handler.ssl.ClientAuth;
@@ -31,6 +33,7 @@
public final class ServerTlsConfigBuilder extends AbstractTlsConfigBuilder {
private ClientAuth clientAuth = ClientAuth.NONE;
+ private TlsEngineType tlsEngineType = Flags.tlsEngineType();
ServerTlsConfigBuilder() {}
@@ -42,10 +45,20 @@ public ServerTlsConfigBuilder clientAuth(ClientAuth clientAuth) {
return this;
}
+ /**
+ * Sets the {@link TlsEngineType} to use for TLS.
+ * If not set, {@link Flags#tlsEngineType()} is used.
+ */
+ public ServerTlsConfigBuilder tlsEngineType(TlsEngineType tlsEngineType) {
+ this.tlsEngineType = requireNonNull(tlsEngineType, "tlsEngineType");
+ return this;
+ }
+
/**
* Returns a newly-created {@link ServerTlsConfig} based on the properties of this builder.
*/
public ServerTlsConfig build() {
- return new ServerTlsConfig(allowsUnsafeCiphers(), meterIdPrefix(), clientAuth, tlsCustomizer());
+ return new ServerTlsConfig(allowsUnsafeCiphers(), meterIdPrefix(), clientAuth,
+ tlsCustomizer(), tlsEngineType);
}
}
diff --git a/core/src/main/java/com/linecorp/armeria/server/ServerTlsProvider.java b/core/src/main/java/com/linecorp/armeria/server/ServerTlsProvider.java
new file mode 100644
index 00000000000..9d532bab80e
--- /dev/null
+++ b/core/src/main/java/com/linecorp/armeria/server/ServerTlsProvider.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2025 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you 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:
+ *
+ * https://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 com.linecorp.armeria.server;
+
+import java.util.concurrent.CompletableFuture;
+
+import com.linecorp.armeria.common.annotation.Nullable;
+import com.linecorp.armeria.common.annotation.UnstableApi;
+
+/**
+ * Resolves TLS configuration from a {@link ConnectionContext} for each new connection.
+ * Unlike {@link com.linecorp.armeria.common.TlsProvider TlsProvider} which resolves TLS
+ * by hostname alone, this interface has access to full connection-level properties
+ * (SNI hostname, ALPN protocols, connection attributes).
+ *
+ * Use this when TLS configuration depends on more than just the hostname, for example
+ * when using xDS filter chain matching.
+ *
+ *
Example usage:
+ *
{@code
+ * Server.builder()
+ * .tlsProvider(ctx -> {
+ * // Resolve TLS based on connection properties
+ * return UnmodifiableFuture.completedFuture(
+ * ServerTlsSpec.builder()
+ * .tlsKeyPair(keyPair)
+ * .build());
+ * })
+ * .service("/api", myService)
+ * .build();
+ * }
+ */
+@UnstableApi
+@FunctionalInterface
+public interface ServerTlsProvider {
+
+ /**
+ * Returns a {@link ServerTlsSpec} for the given {@link ConnectionContext}.
+ *
+ * This method is called by the server pipeline for each new TLS connection.
+ * Implementations can inspect connection properties such as SNI hostname, ALPN protocols,
+ * and custom attributes to determine the appropriate TLS configuration.
+ *
+ * @param ctx the connection context with SNI hostname, ALPN protocols, and attribute access
+ * @return a future resolving to the TLS configuration, or {@code null} to indicate this
+ * provider does not handle the connection (falls through to the next provider)
+ */
+ CompletableFuture<@Nullable ServerTlsSpec> serverTlsSpec(ConnectionContext ctx);
+}
diff --git a/core/src/main/java/com/linecorp/armeria/server/ServerTlsSpec.java b/core/src/main/java/com/linecorp/armeria/server/ServerTlsSpec.java
index 5ee78a7ea56..fd031cfe7a9 100644
--- a/core/src/main/java/com/linecorp/armeria/server/ServerTlsSpec.java
+++ b/core/src/main/java/com/linecorp/armeria/server/ServerTlsSpec.java
@@ -120,11 +120,20 @@ public String toString() {
.toString();
}
- static ServerTlsSpecBuilder builder() {
+ /**
+ * Returns a new {@link ServerTlsSpecBuilder}.
+ */
+ @UnstableApi
+ public static ServerTlsSpecBuilder builder() {
return new ServerTlsSpecBuilder();
}
- static class ServerTlsSpecBuilder extends AbstractTlsSpecBuilder {
+ /**
+ * A builder for {@link ServerTlsSpec}.
+ */
+ @UnstableApi
+ public static class ServerTlsSpecBuilder
+ extends AbstractTlsSpecBuilder {
private ClientAuth clientAuth = ClientAuth.NONE;
private String hostnamePattern = "UNKNOWN";
@@ -132,21 +141,33 @@ static class ServerTlsSpecBuilder extends AbstractTlsSpecBuilder tlsCustomizer) {
this.tlsCustomizer = requireNonNull(tlsCustomizer, "tlsCustomizer");
return this;
}
+ /**
+ * Sets the {@link KeyManagerFactory} to use for TLS.
+ */
ServerTlsSpecBuilder keyManagerFactory(KeyManagerFactory keyManagerFactory) {
this.keyManagerFactory = requireNonNull(keyManagerFactory, "keyManagerFactory");
return this;
diff --git a/core/src/main/java/com/linecorp/armeria/server/ServiceRequestContext.java b/core/src/main/java/com/linecorp/armeria/server/ServiceRequestContext.java
index fde1c4bee2a..7583dc47176 100644
--- a/core/src/main/java/com/linecorp/armeria/server/ServiceRequestContext.java
+++ b/core/src/main/java/com/linecorp/armeria/server/ServiceRequestContext.java
@@ -200,6 +200,14 @@ default ServiceRequestContext root() {
@Override
InetSocketAddress localAddress();
+ /**
+ * Returns the {@link ConnectionContext} for the connection handling this request.
+ * The connection context provides connection-level properties such as SNI hostname
+ * and ALPN protocols.
+ */
+ @UnstableApi
+ ConnectionContext connectionContext();
+
/**
* Returns the address of the client who initiated this request.
*/
diff --git a/core/src/main/java/com/linecorp/armeria/server/ServiceRequestContextBuilder.java b/core/src/main/java/com/linecorp/armeria/server/ServiceRequestContextBuilder.java
index c3fb565b904..d3259562c99 100644
--- a/core/src/main/java/com/linecorp/armeria/server/ServiceRequestContextBuilder.java
+++ b/core/src/main/java/com/linecorp/armeria/server/ServiceRequestContextBuilder.java
@@ -243,11 +243,13 @@ public ServiceRequestContext build() {
// Build the context with the properties set by a user and the fake objects.
final Channel ch = fakeChannel(eventLoop);
+ final ConnectionContext connectionContext =
+ new ConnectionContext(sessionProtocol(), "", null, null, ch);
return new DefaultServiceRequestContext(
serviceCfg, ch, eventLoop, meterRegistry(), sessionProtocol(), id(), routingCtx,
routingResult, exchangeType, req, sslSession(), proxiedAddresses,
clientAddress, remoteAddress(), localAddress(),
- requestCancellationScheduler,
+ connectionContext, requestCancellationScheduler,
isRequestStartTimeSet() ? requestStartTimeNanos() : System.nanoTime(),
isRequestStartTimeSet() ? requestStartTimeMicros() : SystemInfo.currentTimeMicros(),
HttpHeaders.of(), HttpHeaders.of(), serviceCfg.contextHook());
diff --git a/core/src/main/java/com/linecorp/armeria/server/ServiceRequestContextWrapper.java b/core/src/main/java/com/linecorp/armeria/server/ServiceRequestContextWrapper.java
index 43715639c59..c7a14d4388a 100644
--- a/core/src/main/java/com/linecorp/armeria/server/ServiceRequestContextWrapper.java
+++ b/core/src/main/java/com/linecorp/armeria/server/ServiceRequestContextWrapper.java
@@ -75,6 +75,11 @@ public InetSocketAddress localAddress() {
return unwrap().localAddress();
}
+ @Override
+ public ConnectionContext connectionContext() {
+ return unwrap().connectionContext();
+ }
+
@Override
public InetAddress clientAddress() {
return unwrap().clientAddress();
diff --git a/core/src/main/java/com/linecorp/armeria/server/StaticTlsProvider.java b/core/src/main/java/com/linecorp/armeria/server/StaticTlsProvider.java
new file mode 100644
index 00000000000..6212cdddd4a
--- /dev/null
+++ b/core/src/main/java/com/linecorp/armeria/server/StaticTlsProvider.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2025 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you 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:
+ *
+ * https://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 com.linecorp.armeria.server;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+import com.linecorp.armeria.common.annotation.Nullable;
+import com.linecorp.armeria.common.util.UnmodifiableFuture;
+import com.linecorp.armeria.internal.common.TlsProviderUtil;
+
+import io.netty.util.DomainWildcardMappingBuilder;
+import io.netty.util.Mapping;
+
+/**
+ * A {@link ServerTlsProvider} backed by a static hostname→{@link ServerTlsSpec} mapping
+ * built from VirtualHost configurations at server build time.
+ */
+final class StaticTlsProvider implements ServerTlsProvider {
+
+ private final Mapping specMapping;
+
+ /**
+ * Creates a {@link StaticTlsProvider} from VirtualHosts, or returns {@code null} if
+ * no VirtualHost has a {@link ServerTlsSpec}.
+ */
+ @Nullable
+ static StaticTlsProvider of(VirtualHost defaultVirtualHost, List virtualHosts) {
+ final ServerTlsSpec defaultSpec = defaultVirtualHost.serverTlsSpec();
+ if (defaultSpec == null) {
+ // Check if any virtual host has a ServerTlsSpec.
+ for (VirtualHost vh : virtualHosts) {
+ if (vh.serverTlsSpec() != null) {
+ // Found at least one; use the last one as default (matching previous behavior).
+ break;
+ }
+ }
+ // Find a fallback default from virtual hosts.
+ ServerTlsSpec fallbackDefault = null;
+ for (int i = virtualHosts.size() - 1; i >= 0; i--) {
+ final ServerTlsSpec spec = virtualHosts.get(i).serverTlsSpec();
+ if (spec != null) {
+ fallbackDefault = spec;
+ break;
+ }
+ }
+ if (fallbackDefault == null) {
+ return null;
+ }
+ return new StaticTlsProvider(buildMapping(fallbackDefault, virtualHosts));
+ }
+ return new StaticTlsProvider(buildMapping(defaultSpec, virtualHosts));
+ }
+
+ private static Mapping buildMapping(
+ ServerTlsSpec defaultSpec, List virtualHosts) {
+ final DomainWildcardMappingBuilder builder =
+ new DomainWildcardMappingBuilder<>(defaultSpec);
+ for (VirtualHost vh : virtualHosts) {
+ final ServerTlsSpec spec = vh.serverTlsSpec();
+ if (spec != null) {
+ final String pattern = vh.originalHostnamePattern();
+ if (!"*".equals(pattern)) {
+ builder.add(pattern, spec);
+ }
+ }
+ }
+ return builder.build();
+ }
+
+ private StaticTlsProvider(Mapping specMapping) {
+ this.specMapping = specMapping;
+ }
+
+ @Override
+ public CompletableFuture<@Nullable ServerTlsSpec> serverTlsSpec(ConnectionContext ctx) {
+ String hostname = ctx.sniHostname();
+ if (hostname.isEmpty()) {
+ hostname = "*";
+ } else {
+ hostname = TlsProviderUtil.normalizeHostname(hostname);
+ }
+ return UnmodifiableFuture.completedFuture(specMapping.map(hostname));
+ }
+}
diff --git a/core/src/main/java/com/linecorp/armeria/server/TlsProviderAdapter.java b/core/src/main/java/com/linecorp/armeria/server/TlsProviderAdapter.java
new file mode 100644
index 00000000000..a93621fc115
--- /dev/null
+++ b/core/src/main/java/com/linecorp/armeria/server/TlsProviderAdapter.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2025 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you 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:
+ *
+ * https://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 com.linecorp.armeria.server;
+
+import java.security.cert.X509Certificate;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+import com.linecorp.armeria.common.TlsKeyPair;
+import com.linecorp.armeria.common.TlsProvider;
+import com.linecorp.armeria.common.annotation.Nullable;
+import com.linecorp.armeria.common.util.UnmodifiableFuture;
+import com.linecorp.armeria.internal.common.TlsProviderUtil;
+import com.linecorp.armeria.server.ServerTlsSpec.ServerTlsSpecBuilder;
+
+/**
+ * Adapts a {@link TlsProvider} into a {@link ServerTlsProvider} by resolving
+ * {@link ServerTlsSpec} from hostname-based {@link TlsKeyPair} lookup combined
+ * with {@link ServerTlsConfig}.
+ */
+final class TlsProviderAdapter implements ServerTlsProvider {
+
+ private final TlsProvider delegate;
+ private final ServerTlsConfig tlsConfig;
+
+ TlsProviderAdapter(TlsProvider delegate, ServerTlsConfig tlsConfig) {
+ this.delegate = delegate;
+ this.tlsConfig = tlsConfig;
+ }
+
+ @Override
+ public CompletableFuture<@Nullable ServerTlsSpec> serverTlsSpec(ConnectionContext ctx) {
+ String hostname = ctx.sniHostname();
+ if (hostname.isEmpty()) {
+ hostname = "*";
+ } else {
+ hostname = TlsProviderUtil.normalizeHostname(hostname);
+ }
+ final TlsKeyPair keyPair = delegate.keyPair(hostname);
+ if (keyPair == null) {
+ return UnmodifiableFuture.completedFuture(null);
+ }
+ final List trustedCertificates = delegate.trustedCertificates(hostname);
+ final ServerTlsSpecBuilder builder = ServerTlsSpec.builder()
+ .tlsKeyPair(keyPair)
+ .engineType(tlsConfig.tlsEngineType())
+ .tlsCustomizer(tlsConfig.tlsCustomizer())
+ .clientAuth(tlsConfig.clientAuth())
+ .allowUnsafeCiphers(tlsConfig.allowsUnsafeCiphers());
+ if (trustedCertificates != null) {
+ builder.trustedCertificates(trustedCertificates);
+ }
+ return UnmodifiableFuture.completedFuture(builder.build());
+ }
+}
diff --git a/core/src/main/java/com/linecorp/armeria/server/TlsProviderMapping.java b/core/src/main/java/com/linecorp/armeria/server/TlsProviderMapping.java
deleted file mode 100644
index e33862ba91c..00000000000
--- a/core/src/main/java/com/linecorp/armeria/server/TlsProviderMapping.java
+++ /dev/null
@@ -1,83 +0,0 @@
-/*
- * Copyright 2024 LINE Corporation
- *
- * LINE Corporation licenses this file to you 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:
- *
- * https://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 com.linecorp.armeria.server;
-
-import java.security.cert.X509Certificate;
-import java.util.List;
-import java.util.function.Consumer;
-
-import com.linecorp.armeria.common.TlsKeyPair;
-import com.linecorp.armeria.common.TlsProvider;
-import com.linecorp.armeria.common.annotation.Nullable;
-import com.linecorp.armeria.common.util.TlsEngineType;
-import com.linecorp.armeria.internal.common.SslContextFactory;
-import com.linecorp.armeria.internal.common.TlsProviderUtil;
-import com.linecorp.armeria.server.ServerTlsSpec.ServerTlsSpecBuilder;
-
-import io.netty.handler.ssl.ClientAuth;
-import io.netty.handler.ssl.SslContext;
-import io.netty.handler.ssl.SslContextBuilder;
-import io.netty.util.Mapping;
-
-final class TlsProviderMapping implements Mapping {
-
- private final TlsProvider tlsProvider;
- private final TlsEngineType tlsEngineType;
- @Nullable
- private final ServerTlsConfig tlsConfig;
- private final SslContextFactory sslContextFactory;
-
- TlsProviderMapping(TlsProvider tlsProvider, TlsEngineType tlsEngineType,
- @Nullable ServerTlsConfig tlsConfig, SslContextFactory sslContextFactory) {
- this.tlsProvider = tlsProvider;
- this.tlsEngineType = tlsEngineType;
- this.tlsConfig = tlsConfig;
- this.sslContextFactory = sslContextFactory;
- }
-
- @Override
- public SslContext map(@Nullable String hostname) {
- if (hostname == null) {
- hostname = "*";
- } else {
- hostname = TlsProviderUtil.normalizeHostname(hostname);
- }
- final TlsKeyPair keyPair = tlsProvider.keyPair(hostname);
- final List trustedCertificates = tlsProvider.trustedCertificates(hostname);
- final Consumer tlsCustomizer =
- tlsConfig != null ? tlsConfig.tlsCustomizer() : ignored -> {};
- final ClientAuth clientAuth = tlsConfig != null ? tlsConfig.clientAuth() : ClientAuth.NONE;
- final boolean allowUnsafeCiphers = tlsConfig != null ? tlsConfig.allowsUnsafeCiphers() : false;
- if (keyPair == null) {
- throw new IllegalStateException("No TLS key pair found for " + hostname);
- }
- final ServerTlsSpecBuilder builder = ServerTlsSpec.builder()
- .tlsKeyPair(keyPair)
- .engineType(tlsEngineType)
- .tlsCustomizer(tlsCustomizer)
- .clientAuth(clientAuth)
- .allowUnsafeCiphers(allowUnsafeCiphers);
- if (trustedCertificates != null) {
- builder.trustedCertificates(trustedCertificates);
- }
- return sslContextFactory.getOrCreate(builder.build());
- }
-
- void release(SslContext sslContext) {
- sslContextFactory.release(sslContext);
- }
-}
diff --git a/core/src/main/java/com/linecorp/armeria/server/UpdatableServerConfig.java b/core/src/main/java/com/linecorp/armeria/server/UpdatableServerConfig.java
index f9421b09164..a96412245f9 100644
--- a/core/src/main/java/com/linecorp/armeria/server/UpdatableServerConfig.java
+++ b/core/src/main/java/com/linecorp/armeria/server/UpdatableServerConfig.java
@@ -33,13 +33,12 @@
import com.linecorp.armeria.common.RequestId;
import com.linecorp.armeria.common.annotation.Nullable;
import com.linecorp.armeria.common.util.BlockingTaskExecutor;
+import com.linecorp.armeria.internal.common.SslContextFactory;
import io.micrometer.core.instrument.MeterRegistry;
import io.netty.channel.ChannelOption;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.EventLoopGroup;
-import io.netty.handler.ssl.SslContext;
-import io.netty.util.Mapping;
final class UpdatableServerConfig implements ServerConfig {
@@ -59,12 +58,19 @@ DefaultServerConfig delegate() {
// Delegate non-public methods
- /**
- * Returns a map of SslContexts {@link SslContext}.
- */
@Nullable
- Mapping sslContextMapping() {
- return delegate.sslContextMapping();
+ ServerTlsProvider serverTlsProvider() {
+ return delegate.serverTlsProvider();
+ }
+
+ @Nullable
+ SslContextFactory sslContextFactory() {
+ return delegate.sslContextFactory();
+ }
+
+ @Nullable
+ ConnectionAcceptor connectionAcceptor() {
+ return delegate.connectionAcceptor();
}
/**
diff --git a/core/src/main/java/com/linecorp/armeria/server/VirtualHost.java b/core/src/main/java/com/linecorp/armeria/server/VirtualHost.java
index 0d182d11d4c..3d4112299f5 100644
--- a/core/src/main/java/com/linecorp/armeria/server/VirtualHost.java
+++ b/core/src/main/java/com/linecorp/armeria/server/VirtualHost.java
@@ -39,7 +39,6 @@
import com.linecorp.armeria.common.HttpResponse;
import com.linecorp.armeria.common.RequestId;
import com.linecorp.armeria.common.SuccessFunction;
-import com.linecorp.armeria.common.TlsProvider;
import com.linecorp.armeria.common.annotation.Nullable;
import com.linecorp.armeria.common.annotation.UnstableApi;
import com.linecorp.armeria.common.logging.RequestLog;
@@ -53,7 +52,6 @@
import io.netty.channel.EventLoopGroup;
import io.netty.handler.ssl.SslContext;
import io.netty.util.Mapping;
-import io.netty.util.ReferenceCountUtil;
/**
* A name-based virtual host.
@@ -92,7 +90,7 @@ public final class VirtualHost {
@Nullable
private final SslContext sslContext;
@Nullable
- private final TlsProvider tlsProvider;
+ private final ServerTlsSpec serverTlsSpec;
@Nullable
private final TlsEngineType tlsEngineType;
private final Router router;
@@ -120,7 +118,7 @@ public final class VirtualHost {
VirtualHost(String defaultHostname, String hostnamePattern, int port,
@Nullable ServerPort serverPort,
@Nullable SslContext sslContext,
- @Nullable TlsProvider tlsProvider,
+ @Nullable ServerTlsSpec serverTlsSpec,
@Nullable TlsEngineType tlsEngineType,
Iterable serviceConfigs,
ServiceConfig fallbackServiceConfig,
@@ -151,7 +149,7 @@ public final class VirtualHost {
this.port = port;
this.serverPort = serverPort;
this.sslContext = sslContext;
- this.tlsProvider = tlsProvider;
+ this.serverTlsSpec = serverTlsSpec;
this.tlsEngineType = tlsEngineType;
this.defaultServiceNaming = defaultServiceNaming;
this.defaultLogName = defaultLogName;
@@ -185,22 +183,6 @@ public final class VirtualHost {
"accessLoggerMapper.apply() has returned null for virtual host: %s.", hostnamePattern);
}
- VirtualHost withNewSslContext(SslContext sslContext) {
- if (tlsProvider != null) {
- ReferenceCountUtil.release(sslContext);
- throw new IllegalStateException("Cannot set a new SslContext when TlsProvider is set.");
- }
- return new VirtualHost(originalDefaultHostname, originalHostnamePattern, port, serverPort,
- sslContext, null,
- tlsEngineType, serviceConfigs, fallbackServiceConfig,
- RejectedRouteHandler.DISABLED, host -> accessLogger, defaultServiceNaming,
- defaultLogName, requestTimeoutMillis, maxRequestLength, verboseResponses,
- accessLogWriter, blockingTaskExecutor, requestAutoAbortDelayMillis,
- successFunction, multipartUploadsLocation, multipartRemovalStrategy,
- serviceWorkerGroup,
- shutdownSupports, requestIdGenerator);
- }
-
/**
* IDNA ASCII conversion, case normalization and validation.
*/
@@ -381,6 +363,11 @@ public SslContext sslContext() {
return sslContext;
}
+ @Nullable
+ ServerTlsSpec serverTlsSpec() {
+ return serverTlsSpec;
+ }
+
/**
* Returns the {@link TlsEngineType} of this virtual host.
*/
@@ -645,7 +632,7 @@ VirtualHost decorate(@Nullable Function super HttpService, ? extends HttpServi
this.fallbackServiceConfig.withDecoratedService(decorator);
return new VirtualHost(originalDefaultHostname, originalHostnamePattern, port, serverPort,
- sslContext, tlsProvider,
+ sslContext, serverTlsSpec,
tlsEngineType, serviceConfigs, fallbackServiceConfig,
RejectedRouteHandler.DISABLED, host -> accessLogger, defaultServiceNaming,
defaultLogName, requestTimeoutMillis, maxRequestLength, verboseResponses,
diff --git a/core/src/main/java/com/linecorp/armeria/server/VirtualHostBuilder.java b/core/src/main/java/com/linecorp/armeria/server/VirtualHostBuilder.java
index 55e370656e7..a147146e81d 100644
--- a/core/src/main/java/com/linecorp/armeria/server/VirtualHostBuilder.java
+++ b/core/src/main/java/com/linecorp/armeria/server/VirtualHostBuilder.java
@@ -72,7 +72,6 @@
import com.linecorp.armeria.common.ResponseHeaders;
import com.linecorp.armeria.common.SuccessFunction;
import com.linecorp.armeria.common.TlsKeyPair;
-import com.linecorp.armeria.common.TlsProvider;
import com.linecorp.armeria.common.TlsSetters;
import com.linecorp.armeria.common.annotation.Nullable;
import com.linecorp.armeria.common.annotation.UnstableApi;
@@ -1352,7 +1351,7 @@ public VirtualHostBuilder contextHook(Supplier extends AutoCloseable> contextH
*/
VirtualHost build(VirtualHostBuilder template, DependencyInjector dependencyInjector,
@Nullable UnloggedExceptionsReporter unloggedExceptionsReporter,
- ServerErrorHandler serverErrorHandler, @Nullable TlsProvider tlsProvider,
+ ServerErrorHandler serverErrorHandler,
SslContextFactory sslContextFactory) {
requireNonNull(template, "template");
@@ -1513,14 +1512,11 @@ VirtualHost build(VirtualHostBuilder template, DependencyInjector dependencyInje
this.tlsEngineType : template.tlsEngineType;
final ServerTlsSpec serverTlsSpec = buildServerTlsSpec(template, tlsEngineType, hostnamePattern);
- if (serverTlsSpec != null && tlsProvider != null) {
- throw new IllegalStateException("Cannot configure TLS settings with a TlsProvider");
- }
- final SslContext sslContext = sslContext(sslContextFactory, serverTlsSpec);
+ final SslContext sslContext = sslContext(sslContextFactory, serverTlsSpec, portBased);
final VirtualHost virtualHost =
new VirtualHost(defaultHostname, hostnamePattern, port, serverPort,
- sslContext, tlsProvider, tlsEngineType,
+ sslContext, serverTlsSpec, tlsEngineType,
serviceConfigs, fallbackServiceConfig, rejectedRouteHandler,
accessLoggerMapper, defaultServiceNaming, defaultLogName, requestTimeoutMillis,
maxRequestLength, verboseResponses, accessLogWriter, blockingTaskExecutor,
@@ -1553,8 +1549,8 @@ static HttpHeaders mergeDefaultHeaders(HttpHeadersBuilder lowPriorityHeaders,
}
@Nullable
- private SslContext sslContext(SslContextFactory sslContextFactory,
- @Nullable ServerTlsSpec serverTlsSpec) {
+ private static SslContext sslContext(SslContextFactory sslContextFactory,
+ @Nullable ServerTlsSpec serverTlsSpec, boolean portBased) {
if (portBased || serverTlsSpec == null) {
return null;
}
diff --git a/core/src/test/java/com/linecorp/armeria/server/ServerTlsProviderTest.java b/core/src/test/java/com/linecorp/armeria/server/ServerTlsProviderTest.java
index d33b7a52bc6..059eb8d7a3e 100644
--- a/core/src/test/java/com/linecorp/armeria/server/ServerTlsProviderTest.java
+++ b/core/src/test/java/com/linecorp/armeria/server/ServerTlsProviderTest.java
@@ -17,7 +17,6 @@
package com.linecorp.armeria.server;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -146,32 +145,15 @@ void shouldUseNewTlsKeyPair() {
}
@Test
- void disallowTlsProviderWhenTlsSettingsIsSet() {
- assertThatThrownBy(() -> {
- Server.builder()
- .tls(TlsKeyPair.ofSelfSigned())
- .tlsProvider(TlsProvider.of(TlsKeyPair.ofSelfSigned()))
- .build();
- }).isInstanceOf(IllegalStateException.class)
- .hasMessageContaining("Cannot configure TLS settings with a TlsProvider");
-
- assertThatThrownBy(() -> {
- Server.builder()
- .tlsSelfSigned()
- .tlsProvider(TlsProvider.of(TlsKeyPair.ofSelfSigned()))
- .build();
- }).isInstanceOf(IllegalStateException.class)
- .hasMessageContaining("Cannot configure TLS settings with a TlsProvider");
-
- assertThatThrownBy(() -> {
- Server.builder()
- .tlsProvider(TlsProvider.of(TlsKeyPair.ofSelfSigned()))
- .virtualHost("example.com")
- .tls(TlsKeyPair.ofSelfSigned())
- .and()
- .build();
- }).isInstanceOf(IllegalStateException.class)
- .hasMessageContaining("Cannot configure TLS settings with a TlsProvider");
+ void allowTlsProviderWithTlsSettings() {
+ // tls() and tlsProvider(TlsProvider) can coexist — the provider is tried first,
+ // and tls() is the fallback when the provider returns null.
+ final Server server = Server.builder()
+ .tls(TlsKeyPair.ofSelfSigned())
+ .tlsProvider(TlsProvider.of(TlsKeyPair.ofSelfSigned()))
+ .service("/", (ctx, req) -> HttpResponse.of(200))
+ .build();
+ server.closeAsync();
}
private static class SettableTlsProvider implements TlsProvider {
diff --git a/core/src/test/java/com/linecorp/armeria/server/TlsProviderMappingTest.java b/core/src/test/java/com/linecorp/armeria/server/TlsProviderMappingTest.java
deleted file mode 100644
index a647b482170..00000000000
--- a/core/src/test/java/com/linecorp/armeria/server/TlsProviderMappingTest.java
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- * Copyright 2024 LINE Corporation
- *
- * LINE Corporation licenses this file to you 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:
- *
- * https://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 com.linecorp.armeria.server;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
-import org.junit.jupiter.api.Test;
-
-import com.linecorp.armeria.common.Flags;
-import com.linecorp.armeria.common.TlsKeyPair;
-import com.linecorp.armeria.common.TlsProvider;
-import com.linecorp.armeria.common.util.TlsEngineType;
-import com.linecorp.armeria.internal.common.SslContextFactory;
-
-class TlsProviderMappingTest {
-
- private static final SslContextFactory factory = new SslContextFactory(Flags.meterRegistry());
-
- @Test
- void testNoDefault() {
- final TlsProvider tlsProvider = TlsProvider.builder()
- .keyPair("example.com", TlsKeyPair.ofSelfSigned())
- .keyPair("api.example.com", TlsKeyPair.ofSelfSigned())
- .keyPair("foo.com", TlsKeyPair.ofSelfSigned())
- .keyPair("*.foo.com", TlsKeyPair.ofSelfSigned())
- .build();
- final TlsProviderMapping mapping = new TlsProviderMapping(tlsProvider,
- TlsEngineType.OPENSSL,
- ServerTlsConfig.builder().build(),
- factory);
- assertThat(mapping.map("example.com")).isNotNull();
- assertThat(mapping.map("api.example.com")).isNotNull();
- assertThatThrownBy(() -> mapping.map("web.example.com"))
- .isInstanceOf(IllegalStateException.class)
- .hasMessageContaining("No TLS key pair found for web.example.com");
- assertThat(mapping.map("foo.com")).isNotNull();
- assertThat(mapping.map("bar.foo.com")).isNotNull();
- assertThatThrownBy(() -> mapping.map("baz.bar.foo.com"))
- .isInstanceOf(IllegalStateException.class)
- .hasMessageContaining("No TLS key pair found for baz.bar.foo.com");
- }
-
- @Test
- void testWithDefault() {
- final TlsProvider tlsProvider = TlsProvider.builder()
- .keyPair(TlsKeyPair.ofSelfSigned())
- .keyPair("example.com", TlsKeyPair.ofSelfSigned())
- .keyPair("api.example.com", TlsKeyPair.ofSelfSigned())
- .keyPair("foo.com", TlsKeyPair.ofSelfSigned())
- .keyPair("*.foo.com", TlsKeyPair.ofSelfSigned())
- .build();
- final TlsProviderMapping mapping = new TlsProviderMapping(tlsProvider,
- TlsEngineType.OPENSSL,
- ServerTlsConfig.builder().build(),
- factory);
- assertThat(mapping.map("example.com")).isNotNull();
- assertThat(mapping.map("api.example.com")).isNotNull();
- assertThat(mapping.map("web.example.com")).isNotNull();
- assertThat(mapping.map("foo.com")).isNotNull();
- assertThat(mapping.map("bar.foo.com")).isNotNull();
- assertThat(mapping.map("baz.bar.foo.com")).isNotNull();
- }
-}
diff --git a/core/src/test/java/com/linecorp/armeria/server/VirtualHostAnnotatedServiceBindingBuilderTest.java b/core/src/test/java/com/linecorp/armeria/server/VirtualHostAnnotatedServiceBindingBuilderTest.java
index 35ebcfce5fa..e7793753db6 100644
--- a/core/src/test/java/com/linecorp/armeria/server/VirtualHostAnnotatedServiceBindingBuilderTest.java
+++ b/core/src/test/java/com/linecorp/armeria/server/VirtualHostAnnotatedServiceBindingBuilderTest.java
@@ -110,7 +110,7 @@ void testAllConfigsAreSet() {
.multipartUploadsLocation(multipartUploadsLocation)
.requestIdGenerator(serviceRequestIdGenerator)
.build(new TestService())
- .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null,
+ .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(),
sslContextFactory);
assertThat(virtualHost.serviceConfigs()).hasSize(2);
diff --git a/core/src/test/java/com/linecorp/armeria/server/VirtualHostBuilderTest.java b/core/src/test/java/com/linecorp/armeria/server/VirtualHostBuilderTest.java
index db6b8d243d8..9e1ef550866 100644
--- a/core/src/test/java/com/linecorp/armeria/server/VirtualHostBuilderTest.java
+++ b/core/src/test/java/com/linecorp/armeria/server/VirtualHostBuilderTest.java
@@ -171,7 +171,7 @@ void virtualHostWithoutPattern() {
Server.builder()
.virtualHost("foo.com")
.defaultHostname("foo.com")
- .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null,
+ .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(),
sslContextFactory);
assertThat(h.hostnamePattern()).isEqualTo("foo.com");
assertThat(h.defaultHostname()).isEqualTo("foo.com");
@@ -182,7 +182,7 @@ void virtualHostWithPattern() {
final VirtualHost h =
Server.builder().virtualHost("*.foo.com")
.defaultHostname("bar.foo.com")
- .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null,
+ .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(),
sslContextFactory);
assertThat(h.hostnamePattern()).isEqualTo("*.foo.com");
assertThat(h.defaultHostname()).isEqualTo("bar.foo.com");
@@ -194,7 +194,7 @@ void accessLoggerCustomization() {
Server.builder().virtualHost("*.foo.com")
.defaultHostname("bar.foo.com")
.accessLogger(host -> LoggerFactory.getLogger("customize.test"))
- .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null,
+ .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(),
sslContextFactory);
assertThat(h1.accessLogger().getName()).isEqualTo("customize.test");
@@ -202,7 +202,7 @@ void accessLoggerCustomization() {
Server.builder().virtualHost("*.foo.com")
.defaultHostname("bar.foo.com")
.accessLogger(LoggerFactory.getLogger("com.foo.test"))
- .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null,
+ .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(),
sslContextFactory);
assertThat(h2.accessLogger().getName()).isEqualTo("com.foo.test");
}
@@ -265,13 +265,13 @@ void tlsAllowUnsafeCiphersCustomization(String templateTlsAllowUnsafeCiphers,
switch (expectedOutcome) {
case "success":
virtualHostBuilder.build(serverBuilder.virtualHostTemplate, noopDependencyInjector,
- null, ServerErrorHandler.ofDefault(), null, sslContextFactory);
+ null, ServerErrorHandler.ofDefault(), sslContextFactory);
break;
case "failure":
assertThatThrownBy(() -> virtualHostBuilder.build(serverBuilder.virtualHostTemplate,
noopDependencyInjector,
null,
- ServerErrorHandler.ofDefault(), null,
+ ServerErrorHandler.ofDefault(),
sslContextFactory))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("TLS with a bad cipher suite");
@@ -312,7 +312,7 @@ void virtualHostWithMismatch() {
assertThatThrownBy(() -> {
Server.builder().virtualHost("foo.com")
.defaultHostname("bar.com")
- .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null,
+ .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(),
sslContextFactory);
}).isInstanceOf(IllegalArgumentException.class);
}
@@ -322,7 +322,7 @@ void virtualHostWithMismatch2() {
assertThatThrownBy(() -> {
Server.builder().virtualHost("*.foo.com")
.defaultHostname("bar.com")
- .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null,
+ .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(),
sslContextFactory);
}).isInstanceOf(IllegalArgumentException.class);
}
@@ -337,7 +337,7 @@ void precedenceOfDuplicateRoute() throws Exception {
final VirtualHost virtualHost = new VirtualHostBuilder(Server.builder(), true)
.service(routeA, (ctx, req) -> HttpResponse.of(200))
.service(routeB, (ctx, req) -> HttpResponse.of(201))
- .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null,
+ .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(),
sslContextFactory);
assertThat(virtualHost.serviceConfigs().size()).isEqualTo(2);
final RoutingContext routingContext = new DefaultRoutingContext(virtualHost(), "example.com",
@@ -354,12 +354,12 @@ void multipartUploadsLocationCustomization() {
final Path multipartUploadsLocation = FileSystems.getDefault().getPath("logs", "access.log");
final VirtualHost h1 = new VirtualHostBuilder(Server.builder(), false)
.multipartUploadsLocation(multipartUploadsLocation)
- .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null,
+ .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(),
sslContextFactory);
assertThat(h1.multipartUploadsLocation()).isEqualTo(multipartUploadsLocation);
final VirtualHost h2 = new VirtualHostBuilder(Server.builder(), false)
- .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null,
+ .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(),
sslContextFactory);
assertThat(h2.multipartUploadsLocation()).isEqualTo(template.multipartUploadsLocation());
}
@@ -369,12 +369,12 @@ void defaultLogNameCustomization() {
final String defaultLogName = "test";
final VirtualHost h1 = new VirtualHostBuilder(Server.builder(), false)
.defaultLogName(defaultLogName)
- .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null,
+ .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(),
sslContextFactory);
assertThat(h1.defaultLogName()).isEqualTo(defaultLogName);
final VirtualHost h2 = new VirtualHostBuilder(Server.builder(), false)
- .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null,
+ .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(),
sslContextFactory);
assertThat(h2.defaultLogName()).isEqualTo(template.defaultLogName());
}
@@ -384,12 +384,12 @@ void successFunctionCustomization() {
final SuccessFunction successFunction = (ctx, log) -> false;
final VirtualHost h1 = new VirtualHostBuilder(Server.builder(), false)
.successFunction(successFunction)
- .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null,
+ .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(),
sslContextFactory);
assertThat(h1.successFunction()).isEqualTo(successFunction);
final VirtualHost h2 = new VirtualHostBuilder(Server.builder(), false)
- .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(), null,
+ .build(template, noopDependencyInjector, null, ServerErrorHandler.ofDefault(),
sslContextFactory);
assertThat(h2.successFunction()).isEqualTo(template.successFunction());
}
diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ServerConnectionConfigTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ServerConnectionConfigTest.java
new file mode 100644
index 00000000000..a326d611908
--- /dev/null
+++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ServerConnectionConfigTest.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2025 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you 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:
+ *
+ * https://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 com.linecorp.armeria.xds.it;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import com.linecorp.armeria.client.ClientFactory;
+import com.linecorp.armeria.client.WebClient;
+import com.linecorp.armeria.common.AggregatedHttpResponse;
+import com.linecorp.armeria.common.HttpResponse;
+import com.linecorp.armeria.common.HttpStatus;
+import com.linecorp.armeria.server.ServerBuilder;
+import com.linecorp.armeria.testing.junit5.server.SelfSignedCertificateExtension;
+import com.linecorp.armeria.testing.junit5.server.ServerExtension;
+import com.linecorp.armeria.xds.XdsBootstrap;
+import com.linecorp.armeria.xds.XdsServerPlugin;
+
+import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap;
+
+/**
+ * Tests the server-side xDS flow: {@link XdsConnectionConfig} subscribes to a
+ * statically-configured listener with a {@code DownstreamTlsContext}, and the
+ * Armeria server uses the xDS-provided TLS certificate.
+ *
+ * The client trusts only the xDS cert. If a wrong cert were presented,
+ * the TLS handshake would fail.
+ */
+class ServerConnectionConfigTest {
+
+ // xDS cert — pushed via DownstreamTlsContext. The client trusts only this cert.
+ // Uses 127.0.0.1 as the CN so it passes hostname verification when connecting to localhost.
+ @RegisterExtension
+ @Order(0)
+ static final SelfSignedCertificateExtension xdsCert =
+ new SelfSignedCertificateExtension("127.0.0.1");
+
+ @RegisterExtension
+ @Order(1)
+ static final ServerExtension server = new ServerExtension() {
+ @Override
+ protected void configure(ServerBuilder sb) throws Exception {
+ final Path certPath = xdsCert.certificateFile().toPath();
+ final Path keyPath = xdsCert.privateKeyFile().toPath();
+
+ //language=YAML
+ final String bootstrapYaml =
+ """
+ static_resources:
+ listeners:
+ - name: server-listener
+ filter_chains: []
+ default_filter_chain:
+ transport_socket:
+ name: envoy.transport_sockets.downstream_tls
+ typed_config:
+ "@type": type.googleapis.com/envoy.extensions.transport_sockets\
+ .tls.v3.DownstreamTlsContext
+ common_tls_context:
+ tls_certificates:
+ - certificate_chain:
+ filename: "%s"
+ private_key:
+ filename: "%s"
+ """.formatted(certPath, keyPath);
+
+ final Bootstrap bootstrap = XdsResourceReader.fromYaml(bootstrapYaml, Bootstrap.class);
+ final XdsBootstrap xdsBootstrap = XdsBootstrap.builder(bootstrap).build();
+
+ // The plugin registers the port, TlsProvider, and server listener.
+ sb.addPlugin(new XdsServerPlugin(xdsBootstrap, "server-listener"));
+
+ sb.service("/hello", (ctx, req) -> HttpResponse.of("hello from xds"));
+ }
+ };
+
+ @Test
+ void serverPresentsXdsCert() {
+ // The client trusts only the xDS cert.
+ // If the server were to present a different cert, the TLS handshake would fail.
+ final ClientFactory factory =
+ ClientFactory.builder()
+ .tlsCustomizer(b -> b.trustManager(xdsCert.certificateFile()))
+ .build();
+ try {
+ final AggregatedHttpResponse res =
+ WebClient.builder(server.httpsUri())
+ .factory(factory)
+ .build()
+ .blocking()
+ .get("/hello");
+ assertThat(res.status()).isEqualTo(HttpStatus.OK);
+ assertThat(res.contentUtf8()).isEqualTo("hello from xds");
+ } finally {
+ factory.close();
+ }
+ }
+}
diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ServerDecoratorTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ServerDecoratorTest.java
new file mode 100644
index 00000000000..446ca49dff0
--- /dev/null
+++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ServerDecoratorTest.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2025 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you 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:
+ *
+ * https://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 com.linecorp.armeria.xds.it;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import com.linecorp.armeria.client.ClientFactory;
+import com.linecorp.armeria.client.WebClient;
+import com.linecorp.armeria.common.AggregatedHttpResponse;
+import com.linecorp.armeria.common.HttpResponse;
+import com.linecorp.armeria.common.HttpStatus;
+import com.linecorp.armeria.server.ServerBuilder;
+import com.linecorp.armeria.testing.junit5.server.SelfSignedCertificateExtension;
+import com.linecorp.armeria.testing.junit5.server.ServerExtension;
+import com.linecorp.armeria.xds.XdsBootstrap;
+import com.linecorp.armeria.xds.XdsServerPlugin;
+
+import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap;
+
+/**
+ * Tests that server-side xDS HTTP filter decorators are applied.
+ * The {@code test.header_filter} is registered via SPI and adds an
+ * {@code x-xds-decorator: applied} response header.
+ */
+class ServerDecoratorTest {
+
+ @RegisterExtension
+ @Order(0)
+ static final SelfSignedCertificateExtension cert =
+ new SelfSignedCertificateExtension("127.0.0.1");
+
+ @RegisterExtension
+ @Order(1)
+ static final ServerExtension server = new ServerExtension() {
+ @Override
+ protected void configure(ServerBuilder sb) throws Exception {
+ final Path certPath = cert.certificateFile().toPath();
+ final Path keyPath = cert.privateKeyFile().toPath();
+
+ //language=YAML
+ final String bootstrapYaml =
+ """
+ static_resources:
+ listeners:
+ - name: decorator-listener
+ filter_chains: []
+ default_filter_chain:
+ filters:
+ - name: envoy.filters.network.http_connection_manager
+ typed_config:
+ "@type": type.googleapis.com/envoy.extensions.filters\
+ .network.http_connection_manager.v3.HttpConnectionManager
+ stat_prefix: ingress_http
+ route_config:
+ name: local_route
+ virtual_hosts:
+ - name: local_service
+ domains: ["*"]
+ routes:
+ - match:
+ prefix: "/"
+ route:
+ cluster: local
+ http_filters:
+ - name: test.header_filter
+ - name: envoy.filters.http.router
+ typed_config:
+ "@type": type.googleapis.com/envoy.extensions\
+ .filters.http.router.v3.Router
+ transport_socket:
+ name: envoy.transport_sockets.downstream_tls
+ typed_config:
+ "@type": type.googleapis.com/envoy.extensions.transport_sockets\
+ .tls.v3.DownstreamTlsContext
+ common_tls_context:
+ tls_certificates:
+ - certificate_chain:
+ filename: "%s"
+ private_key:
+ filename: "%s"
+ clusters:
+ - name: local
+ type: STATIC
+ """.formatted(certPath, keyPath);
+
+ final Bootstrap bootstrap = XdsResourceReader.fromYaml(bootstrapYaml, Bootstrap.class);
+ final XdsBootstrap xdsBootstrap = XdsBootstrap.builder(bootstrap).build();
+
+ // The plugin registers the port, TlsProvider, and server listener.
+ sb.addPlugin(new XdsServerPlugin(xdsBootstrap, "decorator-listener"));
+
+ sb.service("/hello", (ctx, req) -> HttpResponse.of("hello"));
+ }
+ };
+
+ @Test
+ void xdsDecoratorAddsResponseHeader() {
+ final ClientFactory factory =
+ ClientFactory.builder()
+ .tlsCustomizer(b -> b.trustManager(cert.certificateFile()))
+ .build();
+ try {
+ final AggregatedHttpResponse res =
+ WebClient.builder(server.httpsUri())
+ .factory(factory)
+ .build()
+ .blocking()
+ .get("/hello");
+
+ assertThat(res.status()).isEqualTo(HttpStatus.OK);
+ assertThat(res.contentUtf8()).isEqualTo("hello");
+ assertThat(res.headers().get("x-xds-decorator")).isEqualTo("applied");
+ // Verify serviceAdded was called (not "not-called").
+ // With a single service, the arbitrary ServiceConfig is /hello.
+ assertThat(res.headers().get("x-xds-route")).isEqualTo("/hello");
+ } finally {
+ factory.close();
+ }
+ }
+}
diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ServerMultiPortTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ServerMultiPortTest.java
new file mode 100644
index 00000000000..63e43bdffc2
--- /dev/null
+++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ServerMultiPortTest.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2025 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you 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:
+ *
+ * https://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 com.linecorp.armeria.xds.it;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import com.linecorp.armeria.client.ClientFactory;
+import com.linecorp.armeria.client.WebClient;
+import com.linecorp.armeria.common.AggregatedHttpResponse;
+import com.linecorp.armeria.common.HttpResponse;
+import com.linecorp.armeria.common.HttpStatus;
+import com.linecorp.armeria.server.ServerBuilder;
+import com.linecorp.armeria.server.ServerPort;
+import com.linecorp.armeria.testing.junit5.server.SelfSignedCertificateExtension;
+import com.linecorp.armeria.testing.junit5.server.ServerExtension;
+import com.linecorp.armeria.xds.XdsBootstrap;
+import com.linecorp.armeria.xds.XdsServerPlugin;
+
+import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap;
+
+/**
+ * Tests that a single {@link XdsServerPlugin} can listen on multiple ports via the
+ * varargs constructor ({@code int... ports}).
+ */
+class ServerMultiPortTest {
+
+ @RegisterExtension
+ @Order(0)
+ static final SelfSignedCertificateExtension cert =
+ new SelfSignedCertificateExtension("127.0.0.1");
+
+ @RegisterExtension
+ @Order(1)
+ static final ServerExtension server = new ServerExtension() {
+ @Override
+ protected void configure(ServerBuilder sb) throws Exception {
+ final Path certPath = cert.certificateFile().toPath();
+ final Path keyPath = cert.privateKeyFile().toPath();
+
+ //language=YAML
+ final String bootstrapYaml =
+ """
+ static_resources:
+ listeners:
+ - name: multi-port-listener
+ filter_chains: []
+ default_filter_chain:
+ transport_socket:
+ name: envoy.transport_sockets.downstream_tls
+ typed_config:
+ "@type": type.googleapis.com/envoy.extensions.transport_sockets\
+ .tls.v3.DownstreamTlsContext
+ common_tls_context:
+ tls_certificates:
+ - certificate_chain:
+ filename: "%s"
+ private_key:
+ filename: "%s"
+ """.formatted(certPath, keyPath);
+
+ final Bootstrap bootstrap = XdsResourceReader.fromYaml(bootstrapYaml, Bootstrap.class);
+ final XdsBootstrap xdsBootstrap = XdsBootstrap.builder(bootstrap).build();
+
+ // Single plugin managing two ephemeral ports.
+ sb.addPlugin(new XdsServerPlugin(xdsBootstrap, "multi-port-listener", 0, 0));
+ sb.service("/hello", (ctx, req) -> HttpResponse.of("hello"));
+ }
+ };
+
+ @Test
+ void multiplePortsSinglePlugin() {
+ final List httpsPorts = server.server().activePorts().values().stream()
+ .filter(ServerPort::hasHttps)
+ .map(p -> p.localAddress().getPort())
+ .collect(Collectors.toList());
+ assertThat(httpsPorts).hasSize(2);
+
+ try (ClientFactory factory =
+ ClientFactory.builder()
+ .tlsCustomizer(b -> b.trustManager(cert.certificateFile()))
+ .build()) {
+ for (int port : httpsPorts) {
+ final AggregatedHttpResponse res =
+ WebClient.builder("https://127.0.0.1:" + port)
+ .factory(factory)
+ .build()
+ .blocking()
+ .get("/hello");
+ assertThat(res.status()).isEqualTo(HttpStatus.OK);
+ assertThat(res.contentUtf8()).isEqualTo("hello");
+ }
+ }
+ }
+}
diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ServerMultiplePluginTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ServerMultiplePluginTest.java
new file mode 100644
index 00000000000..f90a3f83faa
--- /dev/null
+++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ServerMultiplePluginTest.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright 2025 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you 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:
+ *
+ * https://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 com.linecorp.armeria.xds.it;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.nio.file.Path;
+import java.security.SignatureException;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import com.linecorp.armeria.client.BlockingWebClient;
+import com.linecorp.armeria.client.ClientTlsSpec;
+import com.linecorp.armeria.client.RequestOptions;
+import com.linecorp.armeria.client.UnprocessedRequestException;
+import com.linecorp.armeria.client.WebClient;
+import com.linecorp.armeria.common.AggregatedHttpResponse;
+import com.linecorp.armeria.common.HttpMethod;
+import com.linecorp.armeria.common.HttpRequest;
+import com.linecorp.armeria.common.HttpResponse;
+import com.linecorp.armeria.common.HttpStatus;
+import com.linecorp.armeria.server.ServerBuilder;
+import com.linecorp.armeria.server.ServerPort;
+import com.linecorp.armeria.testing.junit5.server.SelfSignedCertificateExtension;
+import com.linecorp.armeria.testing.junit5.server.ServerExtension;
+import com.linecorp.armeria.xds.XdsBootstrap;
+import com.linecorp.armeria.xds.XdsServerPlugin;
+
+import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap;
+
+/**
+ * Tests that multiple {@link XdsServerPlugin}s can coexist on the same server,
+ * each managing a different port with its own TLS certificate. This verifies that
+ * {@link com.linecorp.armeria.server.ConnectionAcceptor}s compose correctly
+ * (each plugin defers for ports it doesn't manage).
+ */
+class ServerMultiplePluginTest {
+
+ @RegisterExtension
+ @Order(0)
+ static final SelfSignedCertificateExtension cert1 =
+ new SelfSignedCertificateExtension("127.0.0.1");
+
+ @RegisterExtension
+ @Order(1)
+ static final SelfSignedCertificateExtension cert2 =
+ new SelfSignedCertificateExtension("127.0.0.1");
+
+ @RegisterExtension
+ @Order(2)
+ static final ServerExtension server = new ServerExtension() {
+ @Override
+ protected void configure(ServerBuilder sb) throws Exception {
+ final Path certPath1 = cert1.certificateFile().toPath();
+ final Path keyPath1 = cert1.privateKeyFile().toPath();
+ final Path certPath2 = cert2.certificateFile().toPath();
+ final Path keyPath2 = cert2.privateKeyFile().toPath();
+
+ //language=YAML
+ final String bootstrapYaml1 =
+ """
+ static_resources:
+ listeners:
+ - name: listener-1
+ filter_chains: []
+ default_filter_chain:
+ transport_socket:
+ name: envoy.transport_sockets.downstream_tls
+ typed_config:
+ "@type": type.googleapis.com/envoy.extensions.transport_sockets\
+ .tls.v3.DownstreamTlsContext
+ common_tls_context:
+ tls_certificates:
+ - certificate_chain:
+ filename: "%s"
+ private_key:
+ filename: "%s"
+ """.formatted(certPath1, keyPath1);
+
+ //language=YAML
+ final String bootstrapYaml2 =
+ """
+ static_resources:
+ listeners:
+ - name: listener-2
+ filter_chains: []
+ default_filter_chain:
+ transport_socket:
+ name: envoy.transport_sockets.downstream_tls
+ typed_config:
+ "@type": type.googleapis.com/envoy.extensions.transport_sockets\
+ .tls.v3.DownstreamTlsContext
+ common_tls_context:
+ tls_certificates:
+ - certificate_chain:
+ filename: "%s"
+ private_key:
+ filename: "%s"
+ """.formatted(certPath2, keyPath2);
+
+ final Bootstrap bootstrap1 = XdsResourceReader.fromYaml(bootstrapYaml1, Bootstrap.class);
+ final XdsBootstrap xdsBootstrap1 = XdsBootstrap.builder(bootstrap1).build();
+ final Bootstrap bootstrap2 = XdsResourceReader.fromYaml(bootstrapYaml2, Bootstrap.class);
+ final XdsBootstrap xdsBootstrap2 = XdsBootstrap.builder(bootstrap2).build();
+
+ sb.addPlugin(new XdsServerPlugin(xdsBootstrap1, "listener-1"));
+ sb.addPlugin(new XdsServerPlugin(xdsBootstrap2, "listener-2"));
+ sb.service("/hello", (ctx, req) -> HttpResponse.of("hello"));
+ }
+ };
+
+ @Test
+ void multiplePluginsOnDifferentPorts() {
+ // Each plugin registered its own ephemeral HTTPS port.
+ // activePorts() preserves registration order, so ports align with plugins.
+ final List httpsPorts = server.server().activePorts().values().stream()
+ .filter(ServerPort::hasHttps)
+ .map(p -> p.localAddress().getPort())
+ .collect(Collectors.toList());
+ assertThat(httpsPorts).hasSize(2);
+ final ClientTlsSpec tlsSpec1 = ClientTlsSpec.builder()
+ .trustedCertificates(cert1.certificate())
+ .build();
+ final ClientTlsSpec tlsSpec2 = ClientTlsSpec.builder()
+ .trustedCertificates(cert2.certificate())
+ .build();
+
+ // Port 0 = listener-1 = cert1
+ final BlockingWebClient client1 = WebClient.of("https://127.0.0.1:" + httpsPorts.get(0)).blocking();
+ final AggregatedHttpResponse res1 = client1.execute(HttpRequest.of(HttpMethod.GET, "/hello"),
+ RequestOptions.builder()
+ .clientTlsSpec(tlsSpec1)
+ .build());
+ assertThat(res1.status()).isEqualTo(HttpStatus.OK);
+ assertThat(res1.contentUtf8()).isEqualTo("hello");
+
+ // Port 1 = listener-2 = cert2
+ final BlockingWebClient client2 = WebClient.of("https://127.0.0.1:" + httpsPorts.get(1)).blocking();
+ final AggregatedHttpResponse res2 = client2.execute(HttpRequest.of(HttpMethod.GET, "/hello"),
+ RequestOptions.builder()
+ .clientTlsSpec(tlsSpec2)
+ .build());
+ assertThat(res2.status()).isEqualTo(HttpStatus.OK);
+ assertThat(res2.contentUtf8()).isEqualTo("hello");
+
+ // Wrong cert for port 0 should fail.
+ final BlockingWebClient client3 = WebClient.of("https://127.0.0.1:" + httpsPorts.get(0)).blocking();
+ assertThatThrownBy(() -> client3.execute(HttpRequest.of(HttpMethod.GET, "/hello"),
+ RequestOptions.builder()
+ .clientTlsSpec(tlsSpec2)
+ .build()))
+ .isInstanceOf(UnprocessedRequestException.class)
+ .hasRootCauseInstanceOf(SignatureException.class);
+ }
+}
diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/TestHeaderFilterFactory.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/TestHeaderFilterFactory.java
new file mode 100644
index 00000000000..2c1ab2d67bd
--- /dev/null
+++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/TestHeaderFilterFactory.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2025 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you 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:
+ *
+ * https://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 com.linecorp.armeria.xds.it;
+
+import java.util.List;
+import java.util.function.Function;
+
+import com.google.common.collect.ImmutableList;
+import com.google.protobuf.Any;
+
+import com.linecorp.armeria.common.HttpHeaderNames;
+import com.linecorp.armeria.common.HttpRequest;
+import com.linecorp.armeria.common.HttpResponse;
+import com.linecorp.armeria.common.annotation.Nullable;
+import com.linecorp.armeria.server.HttpService;
+import com.linecorp.armeria.server.ServiceConfig;
+import com.linecorp.armeria.server.ServiceRequestContext;
+import com.linecorp.armeria.server.SimpleDecoratingHttpService;
+import com.linecorp.armeria.xds.filter.FactoryContext;
+import com.linecorp.armeria.xds.filter.HttpFilterFactory;
+import com.linecorp.armeria.xds.filter.XdsHttpFilter;
+
+import io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.HttpFilter;
+
+/**
+ * A test {@link HttpFilterFactory} that adds response headers proving the decorator was applied
+ * and that {@code serviceAdded(ServiceConfig)} was called with the correct config.
+ *
+ * Response headers added:
+ *
+ * - {@code x-xds-decorator: applied}
+ * - {@code x-xds-route: } — the route pattern from the {@link ServiceConfig}
+ * passed to {@code serviceAdded}, or {@code "not-called"} if it was never invoked
+ *
+ */
+public final class TestHeaderFilterFactory implements HttpFilterFactory {
+
+ private static final String NAME = "test.header_filter";
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+
+ @Override
+ public List typeUrls() {
+ return ImmutableList.of();
+ }
+
+ @Nullable
+ @Override
+ public XdsHttpFilter create(HttpFilter httpFilter, Any config, FactoryContext context) {
+ return new XdsHttpFilter() {
+ @Override
+ public Function super HttpService, ? extends HttpService> serverDecorator() {
+ return TestHeaderDecorator::new;
+ }
+ };
+ }
+
+ private static final class TestHeaderDecorator extends SimpleDecoratingHttpService {
+
+ private String routePattern = "not-called";
+
+ TestHeaderDecorator(HttpService delegate) {
+ super(delegate);
+ }
+
+ @Override
+ public void serviceAdded(ServiceConfig cfg) throws Exception {
+ super.serviceAdded(cfg);
+ routePattern = cfg.route().patternString();
+ }
+
+ @Override
+ public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) throws Exception {
+ return unwrap().serve(ctx, req)
+ .mapHeaders(headers -> headers.toBuilder()
+ .add(HttpHeaderNames.of("x-xds-decorator"),
+ "applied")
+ .add(HttpHeaderNames.of("x-xds-route"),
+ routePattern)
+ .build());
+ }
+ }
+}
diff --git a/it/xds-client/src/test/resources/META-INF/services/com.linecorp.armeria.xds.filter.HttpFilterFactory b/it/xds-client/src/test/resources/META-INF/services/com.linecorp.armeria.xds.filter.HttpFilterFactory
new file mode 100644
index 00000000000..bcff612cf4f
--- /dev/null
+++ b/it/xds-client/src/test/resources/META-INF/services/com.linecorp.armeria.xds.filter.HttpFilterFactory
@@ -0,0 +1 @@
+com.linecorp.armeria.xds.it.TestHeaderFilterFactory
diff --git a/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioPodCustomizer.java b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioPodCustomizer.java
index 39aea5a1158..161e6853010 100644
--- a/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioPodCustomizer.java
+++ b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioPodCustomizer.java
@@ -40,6 +40,7 @@ public void customizePod(PodBuilder podBuilder) {
// SotW doesn't have per-resource subscriptions, so this doesn't occur.
.addToAnnotations("proxy.istio.io/config",
"{\"proxyMetadata\":{\"ISTIO_DELTA_XDS\":\"false\"}}")
+ .addToAnnotations("traffic.sidecar.istio.io/excludeOutboundPorts", "8080")
.endMetadata()
.editSpec()
.editMatchingContainer(c -> "test".equals(c.getName()))
diff --git a/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioServerExtension.java b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioServerExtension.java
index 6ad0bb984c1..204a61e75d3 100644
--- a/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioServerExtension.java
+++ b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioServerExtension.java
@@ -20,11 +20,13 @@
import java.time.Duration;
import java.util.List;
import java.util.Map;
+import java.util.function.Consumer;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import com.linecorp.armeria.common.annotation.Nullable;
import com.linecorp.armeria.server.ServerConfigurator;
import io.fabric8.kubernetes.api.model.IntOrString;
@@ -63,15 +65,24 @@ public final class IstioServerExtension extends HostOnlyExtension {
private final String serviceName;
private final int port;
private final Class extends ServerConfigurator> configuratorClass;
+ @Nullable
+ private final Consumer deploymentCustomizer;
public IstioServerExtension(String serviceName, int port,
Class extends ServerConfigurator> configuratorClass) {
+ this(serviceName, port, configuratorClass, null);
+ }
+
+ public IstioServerExtension(String serviceName, int port,
+ Class extends ServerConfigurator> configuratorClass,
+ @Nullable Consumer deploymentCustomizer) {
this.serviceName = requireNonNull(serviceName, "serviceName");
if (port <= 0 || port > 65535) {
throw new IllegalArgumentException("port: " + port + " (expected: 1-65535)");
}
this.port = port;
this.configuratorClass = requireNonNull(configuratorClass, "configuratorClass");
+ this.deploymentCustomizer = deploymentCustomizer;
}
/**
@@ -146,38 +157,43 @@ private void collectServerPodLogs(KubernetesClient client) {
private void createDeployment(KubernetesClient client) {
final Map labels = Map.of("app", serviceName);
+ final DeploymentBuilder builder = new DeploymentBuilder()
+ .withNewMetadata()
+ .withName(serviceName)
+ .withNamespace(NAMESPACE)
+ .endMetadata()
+ .withNewSpec()
+ .withReplicas(1)
+ .withNewSelector()
+ .withMatchLabels(labels)
+ .endSelector()
+ .withNewTemplate()
+ .withNewMetadata()
+ .withLabels(labels)
+ .withAnnotations(Map.of("sidecar.istio.io/inject", "true"))
+ .endMetadata()
+ .withNewSpec()
+ .addNewContainer()
+ .withName("server")
+ .withImage(IstioTestImage.IMAGE_NAME)
+ .withImagePullPolicy("Never")
+ .withArgs("--server-factory", configuratorClass.getName(),
+ "--port", String.valueOf(port))
+ .addNewEnv()
+ .withName("JAVA_TOOL_OPTIONS")
+ .withValue(IstioEnv.podJvmArgs())
+ .endEnv()
+ .endContainer()
+ .endSpec()
+ .endTemplate()
+ .endSpec();
+
+ if (deploymentCustomizer != null) {
+ deploymentCustomizer.accept(builder);
+ }
+
client.apps().deployments().inNamespace(NAMESPACE)
- .resource(new DeploymentBuilder()
- .withNewMetadata()
- .withName(serviceName)
- .withNamespace(NAMESPACE)
- .endMetadata()
- .withNewSpec()
- .withReplicas(1)
- .withNewSelector()
- .withMatchLabels(labels)
- .endSelector()
- .withNewTemplate()
- .withNewMetadata()
- .withLabels(labels)
- .withAnnotations(Map.of("sidecar.istio.io/inject", "true"))
- .endMetadata()
- .withNewSpec()
- .addNewContainer()
- .withName("server")
- .withImage(IstioTestImage.IMAGE_NAME)
- .withImagePullPolicy("Never")
- .withArgs("--server-factory", configuratorClass.getName(),
- "--port", String.valueOf(port))
- .addNewEnv()
- .withName("JAVA_TOOL_OPTIONS")
- .withValue(IstioEnv.podJvmArgs())
- .endEnv()
- .endContainer()
- .endSpec()
- .endTemplate()
- .endSpec()
- .build())
+ .resource(builder.build())
.create();
logger.info("Created deployment '{}' with server-factory '{}'",
serviceName, configuratorClass.getName());
diff --git a/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/XdsClientToServerTest.java b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/XdsClientToServerTest.java
new file mode 100644
index 00000000000..670ce6546d3
--- /dev/null
+++ b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/XdsClientToServerTest.java
@@ -0,0 +1,153 @@
+/*
+ * Copyright 2026 LY Corporation
+ *
+ * LY Corporation licenses this file to you 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:
+ *
+ * https://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 com.linecorp.armeria.it.xds;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.util.function.Consumer;
+
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import com.jayway.jsonpath.JsonPath;
+
+import com.linecorp.armeria.client.ClientRequestContext;
+import com.linecorp.armeria.client.ClientRequestContextCaptor;
+import com.linecorp.armeria.client.Clients;
+import com.linecorp.armeria.client.WebClient;
+import com.linecorp.armeria.client.logging.LoggingClient;
+import com.linecorp.armeria.common.AggregatedHttpResponse;
+import com.linecorp.armeria.common.HttpStatus;
+import com.linecorp.armeria.it.istio.testing.EnabledIfDockerAvailable;
+import com.linecorp.armeria.it.istio.testing.IstioClusterExtension;
+import com.linecorp.armeria.it.istio.testing.IstioPodTest;
+import com.linecorp.armeria.it.istio.testing.IstioServerExtension;
+import com.linecorp.armeria.xds.XdsBootstrap;
+import com.linecorp.armeria.xds.client.endpoint.XdsHttpPreprocessor;
+
+import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap;
+import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder;
+
+@EnabledIfDockerAvailable
+class XdsClientToServerTest {
+
+ private static final int SERVER_PORT = 8080;
+
+ @RegisterExtension
+ @Order(1)
+ static IstioClusterExtension cluster = new IstioClusterExtension();
+
+ @RegisterExtension
+ @Order(2)
+ static IstioServerExtension server =
+ new IstioServerExtension("xds-echo-server", SERVER_PORT, XdsEchoConfigurator.class,
+ new XdsServerDeploymentCustomizer());
+
+ /**
+ * Customizes the server deployment so the Armeria server handles xDS natively
+ * instead of relying on sidecar iptables interception for inbound traffic.
+ */
+ private static class XdsServerDeploymentCustomizer implements Consumer {
+ @Override
+ public void accept(DeploymentBuilder builder) {
+ builder.editSpec()
+ .editTemplate()
+ .editMetadata()
+ .addToAnnotations("traffic.sidecar.istio.io/excludeInboundPorts",
+ String.valueOf(SERVER_PORT))
+ .addToAnnotations("proxy.istio.io/config",
+ "{\"proxyMetadata\":{\"ISTIO_DELTA_XDS\":\"false\"}}")
+ .endMetadata()
+ .editSpec()
+ // Declare the shared volumes ourselves. The Istio webhook normally
+ // injects these when mutating pods, but deployments are validated
+ // before any pod is created, so the volumes must already exist in
+ // the template.
+ .addNewVolume()
+ .withName("workload-socket")
+ .withNewEmptyDir().endEmptyDir()
+ .endVolume()
+ .addNewVolume()
+ .withName("istio-envoy")
+ .withNewEmptyDir().endEmptyDir()
+ .endVolume()
+ .editMatchingContainer(c -> "server".equals(c.getName()))
+ .addNewVolumeMount()
+ .withName("workload-socket")
+ .withMountPath("/var/run/secrets/workload-spiffe-uds")
+ .endVolumeMount()
+ .addNewVolumeMount()
+ .withName("istio-envoy")
+ .withMountPath("/etc/istio/proxy")
+ .endVolumeMount()
+ .endContainer()
+ .endSpec()
+ .endTemplate()
+ .endSpec();
+ }
+ }
+
+ @IstioPodTest
+ void serverRequest() throws Exception {
+ final String serviceIp = InetAddress.getByName(
+ server.serviceName() + ".default.svc.cluster.local").getHostAddress();
+ final String listenerName = serviceIp + '_' + server.port();
+
+ final String bootstrapJson = loadBootstrapJson();
+ final Bootstrap bootstrap = XdsResourceReader.fromJson(bootstrapJson, Bootstrap.class);
+
+ try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap);
+ XdsHttpPreprocessor preprocessor =
+ XdsHttpPreprocessor.ofListener(listenerName, xdsBootstrap)) {
+ final WebClient client = WebClient.builder(preprocessor)
+ .decorator(LoggingClient.newDecorator())
+ .build();
+ final ClientRequestContext ctx;
+ final String responseBody;
+ try (ClientRequestContextCaptor captor = Clients.newContextCaptor()) {
+ final AggregatedHttpResponse response = client.get("/echo").aggregate().join();
+ ctx = captor.get();
+ responseBody = response.contentUtf8();
+ assertThat(response.status()).isEqualTo(HttpStatus.OK);
+ }
+
+ // Verify that the client and server see each other directly —
+ // no sidecar proxy rewrote the connection endpoints.
+ final InetSocketAddress clientRemote = ctx.remoteAddress();
+ final InetSocketAddress clientLocal = ctx.localAddress();
+ assertThat(clientRemote).isNotNull();
+ assertThat(clientLocal).isNotNull();
+
+ final String serverRemoteIp = JsonPath.read(responseBody, "$.remoteIp");
+ final int serverRemotePort = JsonPath.read(responseBody, "$.remotePort");
+ final String serverLocalIp = JsonPath.read(responseBody, "$.localIp");
+ final int serverLocalPort = JsonPath.read(responseBody, "$.localPort");
+
+ // client's remote == server's local
+ assertThat(clientRemote.getAddress().getHostAddress()).isEqualTo(serverLocalIp);
+ assertThat(clientRemote.getPort()).isEqualTo(serverLocalPort);
+ // client's local == server's remote
+ assertThat(clientLocal.getAddress().getHostAddress()).isEqualTo(serverRemoteIp);
+ assertThat(clientLocal.getPort()).isEqualTo(serverRemotePort);
+ }
+ }
+
+ private static String loadBootstrapJson() {
+ return XdsResourceReader.readBootstrap();
+ }
+}
diff --git a/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/XdsEchoConfigurator.java b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/XdsEchoConfigurator.java
new file mode 100644
index 00000000000..f53f6b72f38
--- /dev/null
+++ b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/XdsEchoConfigurator.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2026 LY Corporation
+ *
+ * LY Corporation licenses this file to you 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:
+ *
+ * https://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 com.linecorp.armeria.it.xds;
+
+import java.net.InetSocketAddress;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.linecorp.armeria.common.HttpResponse;
+import com.linecorp.armeria.common.MediaType;
+import com.linecorp.armeria.server.ServerBuilder;
+import com.linecorp.armeria.server.ServerConfigurator;
+import com.linecorp.armeria.server.logging.LoggingService;
+import com.linecorp.armeria.xds.XdsBootstrap;
+import com.linecorp.armeria.xds.XdsServerPlugin;
+
+import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap;
+
+public final class XdsEchoConfigurator implements ServerConfigurator {
+
+ private static final Logger logger = LoggerFactory.getLogger(XdsEchoConfigurator.class);
+
+ private static final String LISTENER_NAME = "virtualInbound";
+ private static final int SERVER_PORT = 8080;
+
+ @Override
+ public void reconfigure(ServerBuilder sb) {
+ sb.service("/echo", (ctx, req) -> {
+ final InetSocketAddress remoteAddr = ctx.remoteAddress();
+ final InetSocketAddress localAddr = ctx.localAddress();
+ final String body = "{\"remoteIp\":\"" + remoteAddr.getAddress().getHostAddress() + "\"," +
+ "\"remotePort\":" + remoteAddr.getPort() + ',' +
+ "\"localIp\":\"" + localAddr.getAddress().getHostAddress() + "\"," +
+ "\"localPort\":" + localAddr.getPort() + '}';
+ return HttpResponse.of(MediaType.JSON, body);
+ });
+ sb.decorator(LoggingService.newDecorator());
+
+ final String rewritten = XdsResourceReader.readBootstrap();
+ final Bootstrap bootstrap = XdsResourceReader.fromJson(rewritten, Bootstrap.class);
+
+ final XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap);
+ // Use the same port as the server so the xDS plugin's connection acceptor
+ // and TLS provider apply to the main server port. Armeria merges duplicate
+ // port entries (the one from IstioPodEntryPoint and this one) automatically.
+ final XdsServerPlugin plugin =
+ new XdsServerPlugin(xdsBootstrap, LISTENER_NAME, SERVER_PORT);
+ sb.addPlugin(plugin);
+ }
+}
diff --git a/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/XdsResourceReader.java b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/XdsResourceReader.java
index 264a29a518e..a397ba7e5ce 100644
--- a/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/XdsResourceReader.java
+++ b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/XdsResourceReader.java
@@ -16,6 +16,12 @@
package com.linecorp.armeria.it.xds;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
@@ -81,5 +87,20 @@ static String addStaticListener(String bootstrapJson, String listenerYaml) {
}
}
+ private static final Path BOOTSTRAP_PATH = Path.of("/etc/istio/proxy/envoy-rev.json");
+
+ /**
+ * Waits for the Istio bootstrap file to exist, reads it, and rewrites the
+ * {@code xds-grpc} cluster to connect directly to Istiod.
+ */
+ static String readBootstrap() {
+ await().untilAsserted(() -> assertThat(BOOTSTRAP_PATH).exists());
+ try {
+ return rewriteXdsGrpcBootstrap(Files.readString(BOOTSTRAP_PATH));
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to read " + BOOTSTRAP_PATH, e);
+ }
+ }
+
private XdsResourceReader() {}
}
diff --git a/xds-api/src/main/proto/envoy/config/listener/v3/listener.proto b/xds-api/src/main/proto/envoy/config/listener/v3/listener.proto
index f6f8e71a18e..b8d48f7b007 100644
--- a/xds-api/src/main/proto/envoy/config/listener/v3/listener.proto
+++ b/xds-api/src/main/proto/envoy/config/listener/v3/listener.proto
@@ -175,6 +175,7 @@ message Listener {
//
// Example using SNI for filter chain selection can be found in the
// :ref:`FAQ entry `.
+ option (armeria.xds.supported.field) = 3;
repeated FilterChain filter_chains = 3;
// Discover filter chains configurations by external service. Dynamic discovery of filter chains is allowed
@@ -210,6 +211,7 @@ message Listener {
// The default filter chain if none of the filter chain matches. If no default filter chain is supplied,
// the connection will be closed. The filter chain match is ignored in this field.
+ option (armeria.xds.supported.field) = 25;
FilterChain default_filter_chain = 25;
// Soft limit on size of the listener’s new connection read and write buffers.
diff --git a/xds-api/src/main/proto/envoy/config/listener/v3/listener_components.proto b/xds-api/src/main/proto/envoy/config/listener/v3/listener_components.proto
index 16b43568f39..1f94ae1906a 100644
--- a/xds-api/src/main/proto/envoy/config/listener/v3/listener_components.proto
+++ b/xds-api/src/main/proto/envoy/config/listener/v3/listener_components.proto
@@ -11,6 +11,7 @@ import "google/protobuf/any.proto";
import "google/protobuf/duration.proto";
import "google/protobuf/wrappers.proto";
+import "armeria/xds/supported.proto";
import "envoy/annotations/deprecation.proto";
import "udpa/annotations/status.proto";
import "udpa/annotations/versioning.proto";
@@ -34,9 +35,11 @@ message Filter {
reserved "config";
// The name of the filter configuration.
+ option (armeria.xds.supported.field) = 1;
string name = 1 [(validate.rules).string = {min_len: 1}];
oneof config_type {
+ option (armeria.xds.supported.oneof_field) = 4;
// Filter specific configuration which depends on the filter being
// instantiated. See the supported filters for further documentation.
// [#extension-category: envoy.filters.network]
@@ -206,6 +209,7 @@ message FilterChain {
reserved "tls_context", "on_demand_configuration";
// The criteria to use when matching a connection to this filter chain.
+ option (armeria.xds.supported.field) = 1;
FilterChainMatch filter_chain_match = 1;
// A list of individual network filters that make up the filter chain for
@@ -218,6 +222,7 @@ message FilterChain {
// to TCP, the onData() method will never be called. Therefore, network filters
// for QUIC listeners should only expect to do work at the start of a new connection
// (i.e. in onNewConnection()). HCM must be the last (or only) filter in the chain.
+ option (armeria.xds.supported.field) = 3;
repeated Filter filters = 3;
// Whether the listener should expect a PROXY protocol V1 header on new
@@ -242,6 +247,7 @@ message FilterChain {
// If no transport socket configuration is specified, new connections
// will be set up with plaintext.
// [#extension-category: envoy.transport_sockets.downstream]
+ option (armeria.xds.supported.field) = 6;
core.v3.TransportSocket transport_socket = 6;
// If present and nonzero, the amount of time to allow incoming connections to complete any
diff --git a/xds-api/src/main/proto/envoy/extensions/transport_sockets/tls/v3/tls.proto b/xds-api/src/main/proto/envoy/extensions/transport_sockets/tls/v3/tls.proto
index d005dafa2b1..d4536c6bad1 100644
--- a/xds-api/src/main/proto/envoy/extensions/transport_sockets/tls/v3/tls.proto
+++ b/xds-api/src/main/proto/envoy/extensions/transport_sockets/tls/v3/tls.proto
@@ -108,10 +108,12 @@ message DownstreamTlsContext {
}
// Common TLS context settings.
+ option (armeria.xds.supported.field) = 1;
CommonTlsContext common_tls_context = 1;
// If specified, Envoy will reject connections without a valid client
// certificate.
+ option (armeria.xds.supported.field) = 2;
google.protobuf.BoolValue require_client_certificate = 2;
// If specified, Envoy will reject connections without a valid and matching SNI.
diff --git a/xds/docs/SERVER_DESIGN.md b/xds/docs/SERVER_DESIGN.md
new file mode 100644
index 00000000000..8c966d96a3d
--- /dev/null
+++ b/xds/docs/SERVER_DESIGN.md
@@ -0,0 +1,295 @@
+# Server-Side xDS Integration
+
+**Status**: Design
+**Author**: @jrhee17
+
+## Summary
+
+Enable Armeria servers to consume xDS configuration from control planes (Istio, CentralDogma,
+etc.) to dynamically manage TLS and cross-cutting policies (RBAC, authentication, telemetry)
+on the user's existing services — without modifying their routing or service definitions.
+
+## Background
+
+Armeria's existing xDS support is client-side: `XdsBootstrap` fetches
+Listener/Route/Cluster/Endpoint resources and feeds them into `XdsEndpointGroup` for service
+discovery and load balancing.
+
+Server-side xDS is a different use case. Instead of discovering upstream services, the Armeria
+server itself becomes an xDS-managed workload. A control plane pushes configuration that governs
+how the server accepts connections, negotiates TLS, and enforces policies like RBAC — while the
+user's services and routing remain entirely under their control.
+
+## Goals
+
+- Dynamic TLS configuration via SDS (cert provisioning, rotation, client auth mode).
+- Dynamic http_filters (RBAC, authn, telemetry) applied as server decorators.
+- Per-connection policy via filter chain selection (mTLS vs plaintext, per-SNI policy).
+- Compatibility with Istio's inbound listener model.
+- User control over which xDS filters are honored.
+
+## Non-Goals
+
+- Dynamic port management. xDS listeners are matched to ports the user already configured.
+ Ports are not dynamically added or removed.
+- Server-side routing via xDS. The user's VirtualHosts/Routes/services are never generated,
+ translated, or modified by xDS.
+- Network filter support beyond HCM. Only `HttpConnectionManager` is supported as a network
+ filter; raw TCP proxying and custom network filters are out of scope.
+
+## Design
+
+## Core Model
+
+In Envoy, the "router" is the terminal http_filter that evaluates VirtualHost/Route matching
+and dispatches to upstream clusters. In Armeria's server-side model, the "router" is the user's
+entire server configuration — VirtualHosts, Routes, services, and service-level decorators. xDS
+manages the layers above it: TLS and http_filters.
+
+**Diagram 1: xDS Listener structure**
+
+### Schema
+
+```
+Listener (name: "inbound_0.0.0.0_8080")
+ └── FilterChain(s)
+ ├── FilterChainMatch
+ │ ├── transport_protocol (e.g. "tls", "raw_buffer")
+ │ ├── application_protocols (e.g. ["istio-peer-exchange", "istio"])
+ │ ├── server_names (e.g. ["api.example.com"])
+ │ ├── prefix_ranges (source/dest IP matching)
+ │ └── source_ports
+ ├── transport_socket
+ │ └── DownstreamTlsContext (SDS certs, client auth mode)
+ └── HttpConnectionManager
+ ├── http_filters
+ │ ├── envoy.filters.http.rbac
+ │ ├── envoy.filters.http.jwt_authn
+ │ └── envoy.filters.http.router
+ └── route_config (simple, e.g. catch-all "*")
+```
+
+### Sample listener
+
+```
+xDS Listener (port 8080)
+ ├── FilterChain: mTLS
+ │ ├── match: transport_protocol="tls", alpn=["istio"]
+ │ ├── TLS: SDS certs + REQUIRE_CLIENT_CERT
+ │ └── http_filters: [RBAC, authn, router]
+ │ ↓
+ ├── FilterChain: plaintext
+ │ ├── match: transport_protocol="raw_buffer"
+ │ └── http_filters: [router]
+ │ ↓
+ └── [router] ← user's Armeria server
+ ├── VirtualHost("api.example.com")
+ │ ├── Route("/api/users") → userService
+ │ ├── Route("/api/admin") → adminService
+ │ └── decorator(LoggingService.newDecorator())
+ ├── VirtualHost("grpc.example.com")
+ │ └── Route("/grpc") → grpcService
+ └── defaultVirtualHost()
+ └── Route("/health") → healthCheckService
+```
+**Diagram 2: Armeria integration**
+
+| Envoy concept | Armeria equivalent |
+|----------------------|---------------------------------------------|
+| Listener | `ServerPort` |
+| FilterChainMatch | `ConnectionAcceptor` / `ConnectionContext` |
+| transport_socket/SDS | `ServerTlsProvider` → `ServerTlsSpec` |
+| http_filters | Per-filter-chain decorator (`HttpService`) |
+| Router filter | User's entire Armeria server config |
+
+## Design Choices
+
+### xDS route_config is not used for service dispatch
+
+The user's own VirtualHost/Route/service configuration is the routing layer. The xDS
+route_config pushed by the control plane is expected to be simple.
+
+### No per-route filter config overrides
+
+In Envoy, each http_filter's behavior can be customized per-route via
+`typed_per_filter_config` on VirtualHost/Route entries in the xDS `RouteConfiguration`.
+For example, an RBAC filter might have a strict policy on one route and a permissive
+policy on another.
+
+Since the router is user code (no xDS `RouteConfiguration`), there are no xDS routes to
+attach `typed_per_filter_config` to. The http_filters config is therefore fixed for the
+entire filter chain — every request on a connection that matched a given filter chain
+sees the same filter config.
+
+### `serviceAdded` Lifecycle
+
+xDS decorators need `serviceAdded(ServiceConfig)` to access server-wide state (MeterRegistry,
+Server reference, etc.). When a new xDS snapshot loads, `serviceAdded` is called on the
+decorator with an arbitrary ServiceConfig.
+
+This matches the existing `RouteDecoratingService` behavior — route-level decorators in
+Armeria already receive `serviceAdded` with an arbitrary ServiceConfig (whichever is iterated
+first), and decorators only use it to access server-wide state (`cfg.server()`,
+`cfg.server().meterRegistry()`), not per-route fields.
+
+The decorator is **not** created per-ServiceConfig or per-connection. One decorator instance
+per filter chain, shared across all connections that match that chain.
+
+### Snapshot Consistency
+
+The xDS snapshot (TLS config + decorator) is captured onto the channel at connection time.
+New connections pick up the latest snapshot; existing connections continue using their
+pinned snapshot. No explicit drain timer is needed — Armeria's connection lifecycle handles it.
+
+### Decorator Ordering
+
+xDS decorators are outermost (run first), so that RBAC rejects before user decorators or
+services see the request:
+
+```
+[xDS RBAC] → [xDS authn] → [user's service decorators] → service
+```
+
+### Connection-Time Binding
+
+The matched filter chain (TLS config + decorator) is determined once at connection
+establishment and does not change for the lifetime of that connection, even if the xDS
+snapshot updates. This matches Envoy's behavior and ensures TLS and policy are consistent
+— both are bound at the same point. New connections pick up the latest snapshot; existing
+connections continue with the policy they were accepted with.
+
+### Port Selection
+
+The port passed to `XdsServerPlugin` is the definitive port to apply xDS policies to.
+Only connections on that port are subject to filter chain matching; connections on other
+server ports are unaffected. A single xDS listener is used regardless of the listener port.
+
+The plugin's bound port is decoupled from the listener's `address.port`. The listener
+port is not validated against the plugin port. This is necessary to support Istio, where
+the `virtualInbound` listener port (15006) is a proxy infrastructure port that differs
+from the application port (8080).
+
+In future, we could support dynamically binding ports to listeners via wildcard listener
+queries, matching standalone Envoy behavior. The API migration would be minimal —
+`XdsServerPlugin.of(XdsBootstrap)` would be all that is needed.
+
+### Plugin API and `ConnectionLevelSetters`
+
+#### Problem: Plugin Composition with User Settings
+
+A `ServerPlugin` (e.g., `XdsServerPlugin`) needs to inspect what the user configured and
+selectively override connection-level fields. For example, the xDS plugin may need to
+override TLS for xDS-controlled ports while leaving user-configured ports alone.
+
+Approaches considered and rejected:
+
+1. **`ServerConfig.toBuilder()`** — `ServerConfig` has derived/resolved state (merged
+ virtual hosts, built SSL contexts) that doesn't round-trip to a builder. Not realistic.
+
+2. **`(ServerBuilder, ServerConfig)` per plugin** — requires an intermediate
+ `buildServerConfig()` between each plugin to produce the `ServerConfig` snapshot.
+ Expensive with N plugins, and intermediate configs may be inconsistent.
+
+3. **Exposing all builder fields** — request-level fields (decorators, error handlers,
+ request ID generators) span both server and vhost levels and require merging at build
+ time. The intermediate builder value is not the resolved value. No existing precedent
+ in Armeria for exposing builder intermediate state.
+
+#### Solution: Expose Only Connection-Level Fields
+
+Connection-level fields are server-only — they don't span the server/vhost merge boundary.
+The builder value IS the resolved value. The set is small and well-defined.
+
+`ConnectionLevelSetters` is an interface that `ServerBuilder` implements, defining both
+getters and setters for connection-level fields. The getters provide read access to the
+intermediate builder state, so plugins can inspect user configuration without intermediate
+builds.
+
+```java
+public interface ConnectionLevelSetters {
+ // Getters (readable intermediate state)
+ List ports();
+ @Nullable ConnectionAcceptor connectionAcceptor();
+ @Nullable ServerTlsProvider tlsProvider();
+
+ // Setters
+ ConnectionLevelSetters port(ServerPort port);
+ ConnectionLevelSetters connectionAcceptor(ConnectionAcceptor acceptor);
+ ConnectionLevelSetters tlsProvider(ServerTlsProvider serverTlsProvider);
+}
+```
+
+The plugin receives the full `ServerBuilder` (so it can call any setter), but the
+interface serves as a documented guarantee that these specific fields have readable
+intermediate state.
+
+```java
+public interface ServerPlugin extends SafeCloseable {
+ void install(ServerBuilder sb);
+}
+```
+
+#### Single-Field, Read-Wrap-Set Pattern
+
+Both `connectionAcceptor` and `tlsProvider` are single-value fields. Setting one replaces
+the previous value. Plugins compose with existing settings by reading the current value,
+wrapping it, and setting the new value:
+
+```java
+// Plugin reads existing, wraps with its logic, delegates on fallback
+ConnectionAcceptor existing = sb.connectionAcceptor();
+sb.connectionAcceptor(ctx -> {
+ Boolean result = myLogic(ctx);
+ if (result != null) {
+ return completedFuture(result);
+ }
+ return existing != null ? existing.accept(ctx)
+ : completedFuture(null);
+});
+```
+
+The same pattern applies to `tlsProvider`: the plugin's `ServerTlsProvider` is tried
+first; if it returns `null`, the previously set provider is consulted. At build time,
+static VirtualHost `tls()` settings are added as a final fallback via
+`FallbackServerTlsProvider`. This means `tls()` and `tlsProvider(ServerTlsProvider)`
+can coexist — the provider takes priority, and `tls()` is the fallback when the
+provider returns `null`.
+
+`tlsProvider(TlsProvider)` and `tlsProvider(ServerTlsProvider)` are mutually exclusive
+— both write to the same single field (the former wraps via `TlsProviderAdapter`).
+
+#### Field Classification
+
+**Connection-level (exposed via `ConnectionLevelSetters`):**
+
+| Field | Plugin interest |
+|------------------------------|-----------------|
+| `ports` | High — xDS adds/controls ports |
+| `connectionAcceptor` | High — xDS filter chain matching |
+| `tlsProvider` | High — xDS cert selection |
+| `maxNumConnections` | Low |
+| `idleTimeoutMillis` | Low |
+| `maxConnectionAgeMillis` | Low |
+
+**Request-level (NOT exposed — require vhost merge):**
+
+| Field | Why not exposed |
+|------------------------------|-----------------|
+| `decorator` | Spans server template + vhost |
+| `errorHandler` | Spans server + vhost |
+| `requestIdGenerator` | Spans server template + vhost |
+| `accessLogWriter` | Spans server template + vhost |
+
+#### VirtualHost Is Request-Time
+
+VirtualHost selection uses the `:authority`/`Host` header + port, which is only available
+at request time (after the HTTP request headers arrive). This is fundamentally different
+from xDS filter chain selection, which uses SNI/ALPN/destination port at connection time.
+
+```
+Connection time (pre-request):
+ SNI + ALPN + port → xDS filter chain → TLS, connection acceptor
+
+Request time:
+ :authority + port → VirtualHost → route → service
+```
diff --git a/xds/docs/SERVER_PROPOSAL.md b/xds/docs/SERVER_PROPOSAL.md
new file mode 100644
index 00000000000..b4c6c25f30b
--- /dev/null
+++ b/xds/docs/SERVER_PROPOSAL.md
@@ -0,0 +1,214 @@
+# Server-Side xDS: Public API Changes
+
+This document summarizes the public API additions and modifications in `core` and `xds`
+required for server-side xDS support. All new APIs are annotated `@UnstableApi`.
+
+---
+
+## 1. How It Fits Together
+
+```
+Server.builder()
+ .addPlugin(new XdsServerPlugin(bootstrap, "listener", 8080, 8443))
+ .service("/api", myService)
+ .build(); -- plugin.install(sb) called here
+```
+
+Internally, `plugin.install(sb)` calls:
+```java
+sb.port(serverPort); // makes sure the port exists, and also ensure http/https are both allowed
+sb.connectionAcceptor(ctx -> ...);
+sb.tlsProvider(ctx -> ...);
+sb.decorator(XdsRootDecorator);
+```
+
+```
+Connection arrives
+ |
+ v
+ConnectionAcceptor.accept(ctx) -- [ConnectionAcceptor, ConnectionContext]
+ | match filter chain, store on ConnectionContext
+ v
+ServerTlsProvider.serverTlsSpec(ctx) -- [ServerTlsProvider, ServerTlsSpec]
+ | read matched chain's ServerTlsSpec
+ v
+TLS handshake
+ |
+ v
+XdsRootDecorator.serve(ctx, req) -- [ServiceRequestContext.connectionContext()]
+ | read matched chain's decorator, delegate
+ v
+User service
+```
+
+### Step 1: Plugin registration
+
+`ServerBuilder.addPlugin(ServerPlugin)` registers the plugin. During `Server` construction,
+`plugin.install(sb)` is called, allowing the plugin to register ports, TLS providers,
+decorators, and connection acceptors in a single call. Plugins are re-installed on
+`Server.reconfigure()` and closed on server stop.
+
+### Step 2: Connection acceptance
+
+When a connection arrives, the `ConnectionAcceptor` chain runs **before** TLS negotiation.
+Multiple acceptors are evaluated last-inserted-first until one returns a non-null result
+(`true` = accept, `false` = reject). Returning `null` defers to the next acceptor; if all
+defer, the connection is accepted. Since plugins are installed during `build()` (after user
+configuration), plugin acceptors naturally run before user-registered ones. This allows
+multiple `XdsServerPlugin`s to coexist — each plugin's acceptor handles only its own port
+and defers on others.
+
+The `ConnectionContext` exposes ClientHello-derived properties (SNI hostname, ALPN protocols)
+plus local/remote addresses and attribute storage. The xDS acceptor matches the connection to
+a filter chain using `destination_port`, `transport_protocol`, `server_names`, and
+`application_protocols`, then stores the matched chain as a `ConnectionContext` attribute.
+
+### Step 3: TLS selection
+
+`ServerTlsProvider.serverTlsSpec(ConnectionContext)` resolves TLS configuration from the
+connection context. Unlike `TlsProvider` (which resolves by hostname alone), it has access
+to the full connection context including attributes set by the acceptor. The xDS provider
+reads the matched filter chain's `ServerTlsSpec` from the attribute.
+
+### Step 4: Request decoration
+
+At request time, `ServiceRequestContext.connectionContext()` gives the decorator access to
+the connection-level attributes. The xDS root decorator checks whether the connection's local
+port is one of its managed ports before applying the matched filter chain's decorator. This
+ensures that when multiple plugins are registered, each decorator only acts on connections
+belonging to its own ports.
+
+---
+
+## 2. New Core APIs
+
+### 2.1 `ServerPlugin`
+
+```java
+package com.linecorp.armeria.server;
+
+public interface ServerPlugin extends SafeCloseable {
+ void install(ServerBuilder sb);
+}
+```
+
+- `install()` is called during `Server` construction and during `Server.reconfigure()`.
+- `close()` is called when the `Server` stops.
+- Plugins registered at construction time are automatically re-installed on reconfigure.
+
+### 2.2 `ConnectionAcceptor`
+
+```java
+package com.linecorp.armeria.server;
+
+public interface ConnectionAcceptor {
+ @Nullable Boolean accept(ConnectionContext ctx);
+}
+```
+
+- `accept()` returns tri-state: `true` (accept), `false` (reject), `null` (defer to next).
+- Multiple acceptors form a chain evaluated last-inserted-first; first non-null result wins.
+ If all return `null`, the connection is accepted by default.
+
+### 2.3 `ConnectionContext`
+
+```java
+package com.linecorp.armeria.server;
+
+public final class ConnectionContext {
+ public SessionProtocol sessionProtocol();
+ public String sniHostname(); // empty string if no SNI
+ @Nullable public List alpnProtocols(); // null if no ALPN
+ public InetSocketAddress localAddress();
+ public InetSocketAddress remoteAddress();
+ @Nullable public T attr(AttributeKey key);
+ public void setAttr(AttributeKey key, @Nullable T value);
+}
+```
+
+### 2.4 `ServerTlsProvider`
+
+```java
+package com.linecorp.armeria.server;
+
+@FunctionalInterface
+public interface ServerTlsProvider {
+ @Nullable ServerTlsSpec serverTlsSpec(ConnectionContext ctx);
+}
+```
+
+- Multiple providers form a chain evaluated last-inserted-first; first non-null
+ `ServerTlsSpec` wins. If all return `null`, no TLS is configured for that connection.
+- `ServerTlsSpec.builder()` and `ServerTlsSpecBuilder` are now public (were package-private).
+
+**Interaction with existing TLS APIs:**
+
+`tlsProvider(...)` entries (`TlsProvider` and `ServerTlsProvider`) participate in the
+same chain with last-inserted-first ordering. `tls(TlsKeyPair)` is always evaluated
+last as a fallback.
+
+For example:
+```
+sb.tls(keyPair); // fallback — always evaluated last
+sb.tlsProvider(tlsProvider); // inserted 1st — evaluated 2nd
+sb.tlsProvider(serverTlsProvider); // inserted 2nd — evaluated 1st
+```
+
+---
+
+## 3. Modified Core APIs
+
+### 3.1 `ServerBuilder`
+
+New methods:
+
+| Method | Description |
+|--------|-------------|
+| `addPlugin(ServerPlugin)` | Registers a plugin installed at build time and on reconfigure |
+| `connectionAcceptor(ConnectionAcceptor)` | Adds a per-connection acceptor to the chain (before TLS) |
+| `tlsProvider(ServerTlsProvider)` | Adds a `ServerTlsProvider` to the chain (new overload) |
+
+Existing `tlsProvider(TlsProvider)` and `tlsProvider(TlsProvider, ServerTlsConfig)` remain
+unchanged. Both `TlsProvider` and `ServerTlsProvider` overloads participate in the same
+last-inserted-first chain. `tls(TlsKeyPair)` is always evaluated last as a fallback.
+
+### 3.2 `ServiceRequestContext`
+
+New method:
+- `connectionContext()` -- returns the `ConnectionContext` for the connection handling
+ this request, giving request-time access to connection-level attributes.
+
+### 3.3 `Server`
+
+- `build()` now calls `plugin.install(sb)` for each registered plugin before building config.
+- `reconfigure()` re-installs all plugins registered at construction time.
+- Server stop calls `plugin.close()` for each plugin.
+
+---
+
+## 4. New xDS APIs
+
+### 4.1 `XdsServerPlugin`
+
+```java
+package com.linecorp.armeria.xds;
+
+public final class XdsServerPlugin implements ServerPlugin {
+ public XdsServerPlugin(XdsBootstrap bootstrap, String listenerName);
+ public XdsServerPlugin(XdsBootstrap bootstrap, String listenerName, int... ports);
+ public XdsServerPlugin(XdsBootstrap bootstrap, String listenerName, ServerPort serverPort);
+ public XdsServerPlugin(XdsBootstrap bootstrap, String listenerName, ServerPort serverPort,
+ Duration readyTimeout);
+}
+```
+
+The primary entry point for server-side xDS. A single plugin can listen on multiple ports;
+the xDS filter chain matching (which uses `destination_port`) handles per-port differentiation.
+`install()` blocks until the first xDS snapshot is resolved, then registers:
+1. **Ports** -- one or more server ports (HTTP + HTTPS)
+2. **ConnectionAcceptor** -- matches connections to xDS filter chains; defers for non-managed ports
+3. **ServerTlsProvider** -- resolves `ServerTlsSpec` from the matched chain
+4. **Root decorator** -- delegates to per-filter-chain decorators at request time.
+ When multiple plugins are registered, `sb.decorator()` causes each plugin's root
+ decorator to be added to the root route. While not ideal, this may be improved via
+ decorator/service querying in the future.
diff --git a/xds/src/main/java/com/linecorp/armeria/xds/DelegatingHttpService.java b/xds/src/main/java/com/linecorp/armeria/xds/DelegatingHttpService.java
new file mode 100644
index 00000000000..909def2d918
--- /dev/null
+++ b/xds/src/main/java/com/linecorp/armeria/xds/DelegatingHttpService.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2025 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you 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:
+ *
+ * https://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 com.linecorp.armeria.xds;
+
+import com.linecorp.armeria.common.HttpRequest;
+import com.linecorp.armeria.common.HttpResponse;
+import com.linecorp.armeria.server.HttpService;
+import com.linecorp.armeria.server.ServiceRequestContext;
+
+import io.netty.util.AttributeKey;
+
+/**
+ * A stateless singleton {@link HttpService} that delegates to an actual service stored in a
+ * {@link ServiceRequestContext} attribute. This allows xDS decorator chains to be pre-built once
+ * and reused across requests, while the actual user service is set per-request on the context.
+ */
+final class DelegatingHttpService implements HttpService {
+
+ private static final DelegatingHttpService INSTANCE = new DelegatingHttpService();
+
+ private static final AttributeKey DELEGATE_KEY =
+ AttributeKey.valueOf(DelegatingHttpService.class, "DELEGATE_KEY");
+
+ static DelegatingHttpService of() {
+ return INSTANCE;
+ }
+
+ static void setDelegate(ServiceRequestContext ctx, HttpService delegate) {
+ ctx.setAttr(DELEGATE_KEY, delegate);
+ }
+
+ private DelegatingHttpService() {
+ }
+
+ @Override
+ public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) throws Exception {
+ final HttpService delegate = ctx.attr(DELEGATE_KEY);
+ if (delegate == null) {
+ return HttpResponse.ofFailure(
+ new IllegalStateException("No delegate service set on the context"));
+ }
+ return delegate.serve(ctx, req);
+ }
+}
diff --git a/xds/src/main/java/com/linecorp/armeria/xds/DownstreamTlsTransportSocketFactory.java b/xds/src/main/java/com/linecorp/armeria/xds/DownstreamTlsTransportSocketFactory.java
new file mode 100644
index 00000000000..c61ebb2e7b0
--- /dev/null
+++ b/xds/src/main/java/com/linecorp/armeria/xds/DownstreamTlsTransportSocketFactory.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright 2025 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you 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:
+ *
+ * https://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 com.linecorp.armeria.xds;
+
+import java.util.List;
+import java.util.Optional;
+
+import com.google.common.collect.ImmutableList;
+
+import com.linecorp.armeria.common.annotation.Nullable;
+import com.linecorp.armeria.xds.stream.SnapshotStream;
+
+import io.envoyproxy.envoy.config.core.v3.ConfigSource;
+import io.envoyproxy.envoy.config.core.v3.TransportSocket;
+import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.CommonTlsContext;
+import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.CommonTlsContext.CombinedCertificateValidationContext;
+import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext;
+import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.SdsSecretConfig;
+import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.Secret;
+import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.TlsCertificate;
+
+/**
+ * Server-side (downstream) TLS transport socket factory. Mirrors
+ * {@link UpstreamTlsTransportSocketFactory} but unpacks {@link DownstreamTlsContext}.
+ */
+final class DownstreamTlsTransportSocketFactory implements TransportSocketFactory {
+
+ static final DownstreamTlsTransportSocketFactory INSTANCE =
+ new DownstreamTlsTransportSocketFactory();
+ private static final String NAME = "envoy.transport_sockets.downstream_tls";
+ private static final String TYPE_URL =
+ "type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext";
+ private static final List TYPE_URLS = ImmutableList.of(TYPE_URL);
+
+ private DownstreamTlsTransportSocketFactory() {}
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+
+ @Override
+ public List typeUrls() {
+ return TYPE_URLS;
+ }
+
+ @Override
+ public SnapshotStream create(
+ SubscriptionContext context, @Nullable ConfigSource configSource,
+ TransportSocket transportSocket) {
+ if (!transportSocket.hasTypedConfig()) {
+ return SnapshotStream.just(new TransportSocketSnapshot(TransportSocket.getDefaultInstance()));
+ }
+ final DownstreamTlsContext tlsContext = context.extensionRegistry().unpack(
+ transportSocket.getTypedConfig(), DownstreamTlsContext.class);
+ final CommonTlsContext commonTlsContext = tlsContext.getCommonTlsContext();
+
+ final SnapshotStream> validationStream;
+
+ if (commonTlsContext.hasValidationContext()) {
+ final Secret secret = Secret.newBuilder()
+ .setValidationContext(commonTlsContext.getValidationContext())
+ .build();
+ final SecretStream secretStream = new SecretStream(secret, context);
+ validationStream = secretStream
+ .switchMapEager(resource -> new CertificateValidationContextStream(context, resource))
+ .map(Optional::of);
+ } else if (commonTlsContext.hasValidationContextSdsSecretConfig()) {
+ final SdsSecretConfig sdsConfig = commonTlsContext.getValidationContextSdsSecretConfig();
+ final SecretStream secretStream = new SecretStream(sdsConfig, configSource, context);
+ validationStream = secretStream
+ .switchMapEager(resource -> new CertificateValidationContextStream(context, resource))
+ .map(Optional::of);
+ } else if (commonTlsContext.hasCombinedValidationContext()) {
+ final CombinedCertificateValidationContext combined =
+ commonTlsContext.getCombinedValidationContext();
+ final SdsSecretConfig sdsConfig = combined.getValidationContextSdsSecretConfig();
+ final SecretStream secretStream = new SecretStream(sdsConfig, configSource, context);
+ validationStream = secretStream.switchMapEager(resource -> new CertificateValidationContextStream(
+ context, resource, combined.getDefaultValidationContext()))
+ .map(Optional::of);
+ } else {
+ validationStream = SnapshotStream.empty();
+ }
+
+ final SnapshotStream> tlsCertStream;
+ if (!commonTlsContext.getTlsCertificatesList().isEmpty()) {
+ final TlsCertificate tlsCertificate = commonTlsContext.getTlsCertificatesList().get(0);
+ final Secret secret = Secret.newBuilder().setTlsCertificate(tlsCertificate).build();
+ final SecretStream secretStream = new SecretStream(secret, context);
+ tlsCertStream = secretStream.switchMapEager(resource -> new TlsCertificateStream(context, resource))
+ .map(Optional::of);
+ } else if (!commonTlsContext.getTlsCertificateSdsSecretConfigsList().isEmpty()) {
+ final SdsSecretConfig sdsConfig =
+ commonTlsContext.getTlsCertificateSdsSecretConfigsList().get(0);
+ final SecretStream secretStream = new SecretStream(sdsConfig, configSource, context);
+ tlsCertStream = secretStream.switchMapEager(resource -> new TlsCertificateStream(context, resource))
+ .map(Optional::of);
+ } else {
+ tlsCertStream = SnapshotStream.empty();
+ }
+
+ return SnapshotStream.combineLatest(tlsCertStream, validationStream, (cert, validation) -> {
+ return new TransportSocketSnapshot(transportSocket, tlsContext, cert, validation);
+ });
+ }
+
+ /**
+ * Returns {@code true} if the {@link DownstreamTlsContext} requires client certificates.
+ */
+ static boolean requireClientCertificate(DownstreamTlsContext tlsContext) {
+ return tlsContext.hasRequireClientCertificate() &&
+ tlsContext.getRequireClientCertificate().getValue();
+ }
+}
diff --git a/xds/src/main/java/com/linecorp/armeria/xds/FilterChainSnapshot.java b/xds/src/main/java/com/linecorp/armeria/xds/FilterChainSnapshot.java
new file mode 100644
index 00000000000..de06c7b7621
--- /dev/null
+++ b/xds/src/main/java/com/linecorp/armeria/xds/FilterChainSnapshot.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2025 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you 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:
+ *
+ * https://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 com.linecorp.armeria.xds;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Objects;
+
+import com.linecorp.armeria.common.annotation.UnstableApi;
+
+/**
+ * A snapshot of a single filter chain, containing the parsed filter chain
+ * and its resolved transport socket.
+ */
+@UnstableApi
+public final class FilterChainSnapshot {
+
+ private final ParsedFilterChain parsedFilterChain;
+ private final TransportSocketSnapshot transportSocketSnapshot;
+
+ FilterChainSnapshot(ParsedFilterChain parsedFilterChain,
+ TransportSocketSnapshot transportSocketSnapshot) {
+ this.parsedFilterChain = parsedFilterChain;
+ this.transportSocketSnapshot = transportSocketSnapshot;
+ }
+
+ /**
+ * Returns the parsed filter chain containing match criteria and the composed server decorator.
+ */
+ public ParsedFilterChain parsedFilterChain() {
+ return parsedFilterChain;
+ }
+
+ /**
+ * Returns the resolved transport socket snapshot for this filter chain.
+ */
+ public TransportSocketSnapshot transportSocketSnapshot() {
+ return transportSocketSnapshot;
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ if (this == object) {
+ return true;
+ }
+ if (object == null || getClass() != object.getClass()) {
+ return false;
+ }
+ final FilterChainSnapshot that = (FilterChainSnapshot) object;
+ return Objects.equal(parsedFilterChain, that.parsedFilterChain) &&
+ Objects.equal(transportSocketSnapshot, that.transportSocketSnapshot);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(parsedFilterChain, transportSocketSnapshot);
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("parsedFilterChain", parsedFilterChain)
+ .add("transportSocketSnapshot", transportSocketSnapshot)
+ .toString();
+ }
+}
diff --git a/xds/src/main/java/com/linecorp/armeria/xds/FilterUtil.java b/xds/src/main/java/com/linecorp/armeria/xds/FilterUtil.java
index 3e4d9ea2b33..273ead6a6fb 100644
--- a/xds/src/main/java/com/linecorp/armeria/xds/FilterUtil.java
+++ b/xds/src/main/java/com/linecorp/armeria/xds/FilterUtil.java
@@ -20,6 +20,7 @@
import java.util.List;
import java.util.Map;
+import java.util.function.Function;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
@@ -30,6 +31,7 @@
import com.linecorp.armeria.client.ClientPreprocessors;
import com.linecorp.armeria.client.ClientPreprocessorsBuilder;
import com.linecorp.armeria.common.annotation.Nullable;
+import com.linecorp.armeria.server.HttpService;
import com.linecorp.armeria.xds.filter.FactoryContext;
import com.linecorp.armeria.xds.filter.HttpFilterFactory;
import com.linecorp.armeria.xds.filter.XdsHttpFilter;
@@ -118,6 +120,42 @@ private static ClientDecoration buildDecoration(@Nullable List fi
return builder.build();
}
+ @Nullable
+ static SnapshotStream> buildDownstreamServerFilter(
+ XdsExtensionRegistry extensionRegistry, FactoryContext factoryContext,
+ List httpFilters) {
+ if (httpFilters.isEmpty()) {
+ return null;
+ }
+ final ImmutableList.Builder> streams = ImmutableList.builder();
+ for (int i = httpFilters.size() - 1; i >= 0; i--) {
+ final HttpFilter httpFilter = httpFilters.get(i);
+ final SnapshotStream stream =
+ resolveInstance(extensionRegistry, factoryContext, httpFilter, null);
+ if (stream != null) {
+ streams.add(stream);
+ }
+ }
+ final ImmutableList> streamList = streams.build();
+ if (streamList.isEmpty()) {
+ return null;
+ }
+ return SnapshotStream.combineNLatest(streamList).map(filters -> {
+ Function composed = Function.identity();
+ for (XdsHttpFilter f : filters) {
+ final Function super HttpService, ? extends HttpService> sd = f.serverDecorator();
+ if (sd != null) {
+ final Function current = composed;
+ @SuppressWarnings("unchecked")
+ final Function casted =
+ (Function) sd;
+ composed = service -> casted.apply(current.apply(service));
+ }
+ }
+ return composed;
+ });
+ }
+
@Nullable
static SnapshotStream resolveInstance(
XdsExtensionRegistry extensionRegistry, FactoryContext factoryContext,
diff --git a/xds/src/main/java/com/linecorp/armeria/xds/ListenerSnapshot.java b/xds/src/main/java/com/linecorp/armeria/xds/ListenerSnapshot.java
index f1c00821040..8bc82e94cd4 100644
--- a/xds/src/main/java/com/linecorp/armeria/xds/ListenerSnapshot.java
+++ b/xds/src/main/java/com/linecorp/armeria/xds/ListenerSnapshot.java
@@ -16,8 +16,11 @@
package com.linecorp.armeria.xds;
+import java.util.List;
+
import com.google.common.base.MoreObjects;
import com.google.common.base.Objects;
+import com.google.common.collect.ImmutableList;
import com.linecorp.armeria.common.annotation.Nullable;
import com.linecorp.armeria.common.annotation.UnstableApi;
@@ -33,14 +36,25 @@ public final class ListenerSnapshot implements Snapshot {
private final ListenerXdsResource listenerXdsResource;
@Nullable
private final RouteSnapshot routeSnapshot;
+ private final List filterChainSnapshots;
+ @Nullable
+ private final FilterChainSnapshot defaultFilterChainSnapshot;
ListenerSnapshot(ListenerXdsResource listenerXdsResource) {
- this(listenerXdsResource, null);
+ this(listenerXdsResource, null, ImmutableList.of(), null);
}
ListenerSnapshot(ListenerXdsResource listenerXdsResource, @Nullable RouteSnapshot routeSnapshot) {
+ this(listenerXdsResource, routeSnapshot, ImmutableList.of(), null);
+ }
+
+ ListenerSnapshot(ListenerXdsResource listenerXdsResource, @Nullable RouteSnapshot routeSnapshot,
+ List filterChainSnapshots,
+ @Nullable FilterChainSnapshot defaultFilterChainSnapshot) {
this.listenerXdsResource = listenerXdsResource;
this.routeSnapshot = routeSnapshot;
+ this.filterChainSnapshots = filterChainSnapshots;
+ this.defaultFilterChainSnapshot = defaultFilterChainSnapshot;
}
@Override
@@ -56,6 +70,23 @@ public RouteSnapshot routeSnapshot() {
return routeSnapshot;
}
+ /**
+ * The resolved filter chain snapshots, in the same order as the
+ * {@link Listener}'s {@code filter_chains} list.
+ */
+ public List filterChainSnapshots() {
+ return filterChainSnapshots;
+ }
+
+ /**
+ * The resolved default filter chain snapshot,
+ * or {@code null} if no default filter chain is configured.
+ */
+ @Nullable
+ public FilterChainSnapshot defaultFilterChainSnapshot() {
+ return defaultFilterChainSnapshot;
+ }
+
@Override
public boolean equals(Object object) {
if (this == object) {
@@ -66,12 +97,15 @@ public boolean equals(Object object) {
}
final ListenerSnapshot that = (ListenerSnapshot) object;
return Objects.equal(listenerXdsResource, that.listenerXdsResource) &&
- Objects.equal(routeSnapshot, that.routeSnapshot);
+ Objects.equal(routeSnapshot, that.routeSnapshot) &&
+ Objects.equal(filterChainSnapshots, that.filterChainSnapshots) &&
+ Objects.equal(defaultFilterChainSnapshot, that.defaultFilterChainSnapshot);
}
@Override
public int hashCode() {
- return Objects.hashCode(listenerXdsResource, routeSnapshot);
+ return Objects.hashCode(listenerXdsResource, routeSnapshot,
+ filterChainSnapshots, defaultFilterChainSnapshot);
}
@Override
@@ -80,6 +114,8 @@ public String toString() {
.omitNullValues()
.add("listenerXdsResource", listenerXdsResource)
.add("routeSnapshot", routeSnapshot)
+ .add("filterChainSnapshots", filterChainSnapshots)
+ .add("defaultFilterChainSnapshot", defaultFilterChainSnapshot)
.toString();
}
diff --git a/xds/src/main/java/com/linecorp/armeria/xds/ListenerStream.java b/xds/src/main/java/com/linecorp/armeria/xds/ListenerStream.java
index 31340024416..de4672082ce 100644
--- a/xds/src/main/java/com/linecorp/armeria/xds/ListenerStream.java
+++ b/xds/src/main/java/com/linecorp/armeria/xds/ListenerStream.java
@@ -18,12 +18,23 @@
import static com.linecorp.armeria.xds.XdsType.LISTENER;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Function;
+
+import com.google.common.collect.ImmutableList;
+
import com.linecorp.armeria.common.annotation.Nullable;
+import com.linecorp.armeria.server.HttpService;
+import com.linecorp.armeria.xds.internal.XdsCommonUtil;
import com.linecorp.armeria.xds.stream.RefCountedStream;
import com.linecorp.armeria.xds.stream.SnapshotStream;
import com.linecorp.armeria.xds.stream.Subscription;
import io.envoyproxy.envoy.config.core.v3.ConfigSource;
+import io.envoyproxy.envoy.config.core.v3.TransportSocket;
+import io.envoyproxy.envoy.config.listener.v3.Filter;
+import io.envoyproxy.envoy.config.listener.v3.FilterChain;
import io.envoyproxy.envoy.config.route.v3.RouteConfiguration;
import io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager;
import io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.Rds;
@@ -67,13 +78,30 @@ protected Subscription onStart(SnapshotWatcher watcher) {
private SnapshotStream resource2snapshot(
ListenerXdsResource resource, @Nullable ConfigSource parentConfigSource) {
- SnapshotStream node = null;
+ // Resolve route (client-side path)
+ final SnapshotStream> routeStream = resolveRoute(resource, parentConfigSource);
+
+ // Resolve filter chain snapshots (server-side path)
+ final SnapshotStream> filterChainSnapshotsStream =
+ resolveFilterChainSnapshots(resource, parentConfigSource);
+ final SnapshotStream> defaultFilterChainStream =
+ resolveDefaultFilterChainSnapshot(resource, parentConfigSource);
+
+ return SnapshotStream.combineLatest(
+ routeStream, filterChainSnapshotsStream, defaultFilterChainStream,
+ (route, filterChainSnapshots, defaultFilterChain) ->
+ new ListenerSnapshot(resource, route.orElse(null),
+ filterChainSnapshots,
+ defaultFilterChain.orElse(null)));
+ }
+
+ private SnapshotStream> resolveRoute(
+ ListenerXdsResource resource, @Nullable ConfigSource parentConfigSource) {
final HttpConnectionManager connectionManager = resource.connectionManager();
if (connectionManager != null) {
if (connectionManager.hasRouteConfig()) {
final RouteConfiguration routeConfig = connectionManager.getRouteConfig();
- node = new RouteStream(context, routeConfig, resource)
- .map(routeSnapshot -> new ListenerSnapshot(resource, routeSnapshot));
+ return new RouteStream(context, routeConfig, resource).map(Optional::of);
} else if (connectionManager.hasRds()) {
final Rds rds = connectionManager.getRds();
final String routeName = rds.getRouteConfigName();
@@ -84,13 +112,84 @@ private SnapshotStream resource2snapshot(
return SnapshotStream.error(new XdsResourceException(LISTENER, resourceName,
"config source not found"));
}
- node = new RouteStream(configSource, routeName, context, resource)
- .map(routeSnapshot -> new ListenerSnapshot(resource, routeSnapshot));
+ return new RouteStream(configSource, routeName, context, resource).map(Optional::of);
}
}
- if (node == null) {
- node = SnapshotStream.just(new ListenerSnapshot(resource));
+ return SnapshotStream.just(Optional.empty());
+ }
+
+ private SnapshotStream> resolveFilterChainSnapshots(
+ ListenerXdsResource resource, @Nullable ConfigSource parentConfigSource) {
+ final List filterChains = resource.resource().getFilterChainsList();
+ if (filterChains.isEmpty()) {
+ return SnapshotStream.just(ImmutableList.of());
+ }
+ final XdsExtensionRegistry registry = context.extensionRegistry();
+ final ImmutableList.Builder> streams = ImmutableList.builder();
+ for (FilterChain filterChain : filterChains) {
+ streams.add(parseOneFilterChain(filterChain, registry)
+ .switchMapEager(parsed -> filterChainSnapshotStream(parsed, parentConfigSource)));
+ }
+ return SnapshotStream.combineNLatest(streams.build());
+ }
+
+ private SnapshotStream> resolveDefaultFilterChainSnapshot(
+ ListenerXdsResource resource, @Nullable ConfigSource parentConfigSource) {
+ if (!resource.resource().hasDefaultFilterChain()) {
+ return SnapshotStream.just(Optional.empty());
+ }
+ final XdsExtensionRegistry registry = context.extensionRegistry();
+ return parseOneFilterChain(resource.resource().getDefaultFilterChain(), registry)
+ .switchMapEager(parsed -> filterChainSnapshotStream(parsed, parentConfigSource))
+ .map(Optional::of);
+ }
+
+ private SnapshotStream filterChainSnapshotStream(
+ ParsedFilterChain parsed, @Nullable ConfigSource parentConfigSource) {
+ final TransportSocket transportSocket = parsed.transportSocket() != null ?
+ parsed.transportSocket()
+ : TransportSocket.getDefaultInstance();
+ return new TransportSocketStream(context, parentConfigSource, transportSocket)
+ .map(ts -> new FilterChainSnapshot(parsed, ts));
+ }
+
+ private SnapshotStream parseOneFilterChain(FilterChain filterChain,
+ XdsExtensionRegistry registry) {
+ final HttpConnectionManager hcm = extractHcm(filterChain, registry);
+ final long maxConnectionDurationMillis = extractMaxConnectionDuration(hcm);
+ final SnapshotStream> decoratorStream =
+ hcm != null ? FilterUtil.buildDownstreamServerFilter(
+ registry, context, hcm.getHttpFiltersList()) : null;
+ if (decoratorStream == null) {
+ return SnapshotStream.just(
+ new ParsedFilterChain(filterChain, null, maxConnectionDurationMillis));
+ }
+ return decoratorStream.map(
+ decorator -> new ParsedFilterChain(filterChain, decorator, maxConnectionDurationMillis));
+ }
+
+ private static long extractMaxConnectionDuration(@Nullable HttpConnectionManager hcm) {
+ if (hcm == null || !hcm.hasCommonHttpProtocolOptions()) {
+ return 0;
+ }
+ return XdsCommonUtil.durationToMillis(
+ hcm.getCommonHttpProtocolOptions().getMaxConnectionDuration(), 0);
+ }
+
+ @Nullable
+ private static HttpConnectionManager extractHcm(FilterChain filterChain,
+ XdsExtensionRegistry registry) {
+ for (Filter filter : filterChain.getFiltersList()) {
+ if (!filter.hasTypedConfig()) {
+ continue;
+ }
+ final HttpConnectionManagerFactory factory =
+ registry.queryByTypeUrl(filter.getTypedConfig().getTypeUrl(),
+ HttpConnectionManagerFactory.class);
+ if (factory != null) {
+ return factory.create(filter.getTypedConfig(), registry.validator());
+ }
}
- return node;
+ return null;
}
}
diff --git a/xds/src/main/java/com/linecorp/armeria/xds/ParsedFilterChain.java b/xds/src/main/java/com/linecorp/armeria/xds/ParsedFilterChain.java
new file mode 100644
index 00000000000..d2f4ce6b6f9
--- /dev/null
+++ b/xds/src/main/java/com/linecorp/armeria/xds/ParsedFilterChain.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2025 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you 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:
+ *
+ * https://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 com.linecorp.armeria.xds;
+
+import java.util.function.Function;
+
+import com.linecorp.armeria.common.annotation.Nullable;
+import com.linecorp.armeria.common.annotation.UnstableApi;
+import com.linecorp.armeria.server.HttpService;
+
+import io.envoyproxy.envoy.config.core.v3.TransportSocket;
+import io.envoyproxy.envoy.config.listener.v3.FilterChain;
+import io.envoyproxy.envoy.config.listener.v3.FilterChainMatch;
+
+/**
+ * A parsed representation of a single {@link FilterChain} from a Listener's
+ * {@code filter_chains} list. Contains the match criteria, transport socket,
+ * and composed server decorator from HTTP filters.
+ */
+@UnstableApi
+public final class ParsedFilterChain {
+
+ private final FilterChain filterChain;
+ private final FilterChainMatch filterChainMatch;
+ @Nullable
+ private final TransportSocket transportSocket;
+ @Nullable
+ private final Function super HttpService, ? extends HttpService> serverDecorator;
+ private final long maxConnectionDurationMillis;
+
+ ParsedFilterChain(FilterChain filterChain,
+ @Nullable Function super HttpService, ? extends HttpService> serverDecorator,
+ long maxConnectionDurationMillis) {
+ this.filterChain = filterChain;
+ this.filterChainMatch = filterChain.hasFilterChainMatch() ?
+ filterChain.getFilterChainMatch()
+ : FilterChainMatch.getDefaultInstance();
+ this.transportSocket = filterChain.hasTransportSocket() ? filterChain.getTransportSocket() : null;
+ this.serverDecorator = serverDecorator;
+ this.maxConnectionDurationMillis = maxConnectionDurationMillis;
+ }
+
+ /**
+ * Returns the raw {@link FilterChain} proto.
+ */
+ public FilterChain filterChain() {
+ return filterChain;
+ }
+
+ /**
+ * Returns the {@link FilterChainMatch} criteria for this chain.
+ */
+ public FilterChainMatch filterChainMatch() {
+ return filterChainMatch;
+ }
+
+ /**
+ * Returns the {@link TransportSocket} for this chain, or {@code null} if not set.
+ */
+ @Nullable
+ public TransportSocket transportSocket() {
+ return transportSocket;
+ }
+
+ /**
+ * Returns the composed server decorator from the HTTP filters in this chain,
+ * or {@code null} if no filters produce server decorators.
+ */
+ @Nullable
+ public Function super HttpService, ? extends HttpService> serverDecorator() {
+ return serverDecorator;
+ }
+
+ /**
+ * Returns the {@code max_connection_duration} from the HCM's
+ * {@code common_http_protocol_options} in milliseconds, or {@code 0} if not set.
+ */
+ public long maxConnectionDurationMillis() {
+ return maxConnectionDurationMillis;
+ }
+}
diff --git a/xds/src/main/java/com/linecorp/armeria/xds/ServerSnapshotWatcher.java b/xds/src/main/java/com/linecorp/armeria/xds/ServerSnapshotWatcher.java
new file mode 100644
index 00000000000..6c7550cba33
--- /dev/null
+++ b/xds/src/main/java/com/linecorp/armeria/xds/ServerSnapshotWatcher.java
@@ -0,0 +1,265 @@
+/*
+ * Copyright 2025 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you 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:
+ *
+ * https://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 com.linecorp.armeria.xds;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Function;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+
+import com.linecorp.armeria.common.annotation.Nullable;
+import com.linecorp.armeria.common.annotation.UnstableApi;
+import com.linecorp.armeria.server.ConnectionContext;
+import com.linecorp.armeria.server.HttpService;
+import com.linecorp.armeria.server.ServerTlsSpec;
+import com.linecorp.armeria.server.ServiceCallbackInvoker;
+import com.linecorp.armeria.server.ServiceConfig;
+
+import io.envoyproxy.envoy.config.listener.v3.FilterChainMatch;
+import io.netty.util.AttributeKey;
+
+/**
+ * A {@link SnapshotWatcher} that watches a {@link ListenerRoot} and resolves filter chains
+ * (TLS + decorators) for server-side xDS integration.
+ *
+ * Typically used via {@link XdsServerPlugin} which encapsulates the wiring:
+ *
{@code
+ * Server.builder()
+ * .addPlugin(new XdsServerPlugin(xdsBootstrap, "listener"))
+ * .service("/api", myService)
+ * .build();
+ * }
+ */
+@UnstableApi
+final class ServerSnapshotWatcher implements SnapshotWatcher {
+
+ static final AttributeKey MATCHED_FILTER_CHAIN =
+ AttributeKey.valueOf(ServerSnapshotWatcher.class, "MATCHED_FILTER_CHAIN");
+
+ private final CompletableFuture readyFuture = new CompletableFuture<>();
+ @Nullable
+ private volatile ResolvedSnapshot snapshot;
+ @Nullable
+ private volatile ServiceConfig serviceConfig;
+
+ /**
+ * Returns a {@link CompletableFuture} that completes when the first xDS snapshot is resolved.
+ * Can be used to delay server startup until xDS configuration is available.
+ */
+ CompletableFuture whenReady() {
+ return readyFuture;
+ }
+
+ /**
+ * Stores the {@link ServiceConfig} and invokes {@code serviceAdded} on all filter chain
+ * decorators in the current snapshot. When a new snapshot arrives later,
+ * {@code serviceAdded} is automatically invoked on its decorators using the stored config.
+ */
+ void setServiceConfig(ServiceConfig serviceConfig) {
+ this.serviceConfig = serviceConfig;
+ invokeServiceAdded(serviceConfig);
+ }
+
+ private void invokeServiceAdded(ServiceConfig serviceConfig) {
+ final ResolvedSnapshot current = snapshot;
+ if (current == null) {
+ return;
+ }
+ for (ResolvedFilterChain chain : current.filterChains) {
+ chain.invokeServiceAdded(serviceConfig);
+ }
+ if (current.defaultFilterChain != null) {
+ current.defaultFilterChain.invokeServiceAdded(serviceConfig);
+ }
+ }
+
+ @Nullable
+ ResolvedFilterChain match(ConnectionContext ctx) {
+ final ResolvedSnapshot current = snapshot;
+ return current != null ? current.match(ctx) : null;
+ }
+
+ @Override
+ public void onUpdate(@Nullable ListenerSnapshot listenerSnapshot, @Nullable Throwable t) {
+ if (t != null) {
+ readyFuture.completeExceptionally(t);
+ return;
+ }
+ if (listenerSnapshot == null) {
+ return;
+ }
+
+ final List filterChainSnapshots =
+ listenerSnapshot.filterChainSnapshots();
+ final FilterChainSnapshot defaultFilterChainSnapshot =
+ listenerSnapshot.defaultFilterChainSnapshot();
+
+ if (filterChainSnapshots.isEmpty() && defaultFilterChainSnapshot == null) {
+ snapshot = ResolvedSnapshot.EMPTY;
+ readyFuture.complete(null);
+ return;
+ }
+
+ final ImmutableList.Builder chainsBuilder = ImmutableList.builder();
+ for (FilterChainSnapshot fcs : filterChainSnapshots) {
+ chainsBuilder.add(resolveFilterChain(fcs));
+ }
+ final ResolvedFilterChain resolvedDefault =
+ defaultFilterChainSnapshot != null ?
+ resolveFilterChain(defaultFilterChainSnapshot) : null;
+ snapshot = new ResolvedSnapshot(chainsBuilder.build(), resolvedDefault);
+ final ServiceConfig cfg = serviceConfig;
+ if (cfg != null) {
+ invokeServiceAdded(cfg);
+ }
+ readyFuture.complete(null);
+ }
+
+ private static ResolvedFilterChain resolveFilterChain(FilterChainSnapshot fcs) {
+ final ParsedFilterChain parsed = fcs.parsedFilterChain();
+ final TransportSocketSnapshot ts = fcs.transportSocketSnapshot();
+ final ServerTlsSpec serverTlsSpec = ts.serverTlsSpec();
+ final Function super HttpService, ? extends HttpService> serverDecorator =
+ parsed.serverDecorator();
+ final HttpService decorator = serverDecorator != null ?
+ serverDecorator.apply(DelegatingHttpService.of()) : null;
+ return new ResolvedFilterChain(parsed.filterChainMatch(), serverTlsSpec, decorator,
+ parsed.maxConnectionDurationMillis());
+ }
+
+ static final class ResolvedSnapshot {
+ static final ResolvedSnapshot EMPTY = new ResolvedSnapshot(ImmutableList.of(), null);
+
+ final List filterChains;
+ @Nullable
+ final ResolvedFilterChain defaultFilterChain;
+
+ ResolvedSnapshot(List filterChains,
+ @Nullable ResolvedFilterChain defaultFilterChain) {
+ this.filterChains = filterChains;
+ this.defaultFilterChain = defaultFilterChain;
+ }
+
+ @Nullable
+ ResolvedFilterChain match(ConnectionContext ctx) {
+ final int port = ctx.localAddress().getPort();
+ final String transportProtocol = ctx.sessionProtocol().isTls() ? "tls" : "raw_buffer";
+ final String sniHostname = ctx.sniHostname();
+ final List alpnProtocols = ctx.alpnProtocols();
+ for (ResolvedFilterChain chain : filterChains) {
+ if (chain.matches(port, transportProtocol, sniHostname, alpnProtocols)) {
+ return chain;
+ }
+ }
+ return defaultFilterChain;
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("filterChains", filterChains)
+ .add("defaultFilterChain", defaultFilterChain)
+ .toString();
+ }
+ }
+
+ static final class ResolvedFilterChain {
+ private final FilterChainMatch filterChainMatch;
+ @Nullable
+ private final ServerTlsSpec serverTlsSpec;
+ @Nullable
+ private final HttpService decorator;
+ private final long maxConnectionDurationMillis;
+
+ ResolvedFilterChain(FilterChainMatch filterChainMatch,
+ @Nullable ServerTlsSpec serverTlsSpec,
+ @Nullable HttpService decorator,
+ long maxConnectionDurationMillis) {
+ this.filterChainMatch = filterChainMatch;
+ this.serverTlsSpec = serverTlsSpec;
+ this.decorator = decorator;
+ this.maxConnectionDurationMillis = maxConnectionDurationMillis;
+ }
+
+ @Nullable
+ ServerTlsSpec serverTlsSpec() {
+ return serverTlsSpec;
+ }
+
+ @Nullable
+ HttpService decorator() {
+ return decorator;
+ }
+
+ long maxConnectionDurationMillis() {
+ return maxConnectionDurationMillis;
+ }
+
+ void invokeServiceAdded(ServiceConfig serviceConfig) {
+ if (decorator != null) {
+ ServiceCallbackInvoker.invokeServiceAdded(serviceConfig, decorator);
+ }
+ }
+
+ boolean matches(int destinationPort, String transportProtocol,
+ String sniHostname, @Nullable List alpnProtocols) {
+ if (filterChainMatch.hasDestinationPort() &&
+ filterChainMatch.getDestinationPort().getValue() != destinationPort) {
+ return false;
+ }
+
+ final String matchTransport = filterChainMatch.getTransportProtocol();
+ if (!matchTransport.isEmpty() && !matchTransport.equals(transportProtocol)) {
+ return false;
+ }
+
+ final List serverNames = filterChainMatch.getServerNamesList();
+ if (!serverNames.isEmpty() && !sniHostname.isEmpty()) {
+ if (!serverNames.contains(sniHostname)) {
+ return false;
+ }
+ }
+
+ final List matchAlpn = filterChainMatch.getApplicationProtocolsList();
+ if (!matchAlpn.isEmpty() && alpnProtocols != null) {
+ boolean found = false;
+ for (String offered : alpnProtocols) {
+ if (matchAlpn.contains(offered)) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ @Override
+ public String toString() {
+ return MoreObjects.toStringHelper(this)
+ .add("filterChainMatch", filterChainMatch)
+ .add("serverTlsSpec", serverTlsSpec)
+ .add("decorator", decorator)
+ .toString();
+ }
+ }
+}
diff --git a/xds/src/main/java/com/linecorp/armeria/xds/TransportSocketSnapshot.java b/xds/src/main/java/com/linecorp/armeria/xds/TransportSocketSnapshot.java
index 87575a29234..abf47a1e22d 100644
--- a/xds/src/main/java/com/linecorp/armeria/xds/TransportSocketSnapshot.java
+++ b/xds/src/main/java/com/linecorp/armeria/xds/TransportSocketSnapshot.java
@@ -33,9 +33,13 @@
import com.linecorp.armeria.common.TlsPeerVerifierFactory;
import com.linecorp.armeria.common.annotation.Nullable;
import com.linecorp.armeria.common.annotation.UnstableApi;
+import com.linecorp.armeria.server.ServerTlsSpec;
+import com.linecorp.armeria.server.ServerTlsSpec.ServerTlsSpecBuilder;
import io.envoyproxy.envoy.config.core.v3.TransportSocket;
+import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext;
import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext;
+import io.netty.handler.ssl.ClientAuth;
/**
* A snapshot of a {@link TransportSocket} resource with its associated TLS configuration.
@@ -55,12 +59,15 @@ public final class TransportSocketSnapshot implements Snapshot
private final CertificateValidationContextSnapshot validationContext;
@Nullable
private final ClientTlsSpec clientTlsSpec;
+ @Nullable
+ private final ServerTlsSpec serverTlsSpec;
TransportSocketSnapshot(TransportSocket transportSocket) {
this.transportSocket = transportSocket;
tlsCertificate = null;
validationContext = null;
clientTlsSpec = null;
+ serverTlsSpec = null;
}
TransportSocketSnapshot(TransportSocket transportSocket,
@@ -71,6 +78,18 @@ public final class TransportSocketSnapshot implements Snapshot
this.tlsCertificate = tlsCertificate.orElse(null);
this.validationContext = validationContext.orElse(null);
clientTlsSpec = buildClientTlsSpec(upstreamTlsContext, this.tlsCertificate, this.validationContext);
+ serverTlsSpec = null;
+ }
+
+ TransportSocketSnapshot(TransportSocket transportSocket,
+ DownstreamTlsContext downstreamTlsContext,
+ Optional tlsCertificate,
+ Optional validationContext) {
+ this.transportSocket = transportSocket;
+ this.tlsCertificate = tlsCertificate.orElse(null);
+ this.validationContext = validationContext.orElse(null);
+ clientTlsSpec = null;
+ serverTlsSpec = buildServerTlsSpec(downstreamTlsContext, this.tlsCertificate, this.validationContext);
}
@Override
@@ -96,12 +115,20 @@ public TransportSocket xdsResource() {
/**
* Returns the {@link ClientTlsSpec} resolved for this transport socket, or {@code null}
- * if this transport socket does not configure TLS.
+ * if this transport socket does not configure upstream TLS.
*/
public @Nullable ClientTlsSpec clientTlsSpec() {
return clientTlsSpec;
}
+ /**
+ * Returns the {@link ServerTlsSpec} resolved for this transport socket, or {@code null}
+ * if this transport socket does not configure downstream TLS.
+ */
+ public @Nullable ServerTlsSpec serverTlsSpec() {
+ return serverTlsSpec;
+ }
+
private static ClientTlsSpec buildClientTlsSpec(
@Nullable UpstreamTlsContext upstreamTlsContext,
@Nullable TlsCertificateSnapshot tlsCertificate,
@@ -152,6 +179,28 @@ private static ClientTlsSpec buildClientTlsSpec(
return specBuilder.build();
}
+ @Nullable
+ private static ServerTlsSpec buildServerTlsSpec(
+ DownstreamTlsContext downstreamTlsContext,
+ @Nullable TlsCertificateSnapshot tlsCertificate,
+ @Nullable CertificateValidationContextSnapshot validationContext) {
+ if (tlsCertificate == null || tlsCertificate.tlsKeyPair() == null) {
+ return null;
+ }
+ final ServerTlsSpecBuilder builder = ServerTlsSpec.builder();
+ builder.tlsKeyPair(tlsCertificate.tlsKeyPair());
+ if (DownstreamTlsTransportSocketFactory.requireClientCertificate(downstreamTlsContext)) {
+ builder.clientAuth(ClientAuth.REQUIRE);
+ }
+ if (validationContext != null) {
+ final List trustedCa = validationContext.trustedCa();
+ if (trustedCa != null) {
+ builder.trustedCertificates(trustedCa);
+ }
+ }
+ return builder.build();
+ }
+
private static void warnNoVerifyOnce() {
if (!warnedNoVerify) {
warnedNoVerify = true;
diff --git a/xds/src/main/java/com/linecorp/armeria/xds/XdsExtensionRegistry.java b/xds/src/main/java/com/linecorp/armeria/xds/XdsExtensionRegistry.java
index 759d107b11e..9153a49a56c 100644
--- a/xds/src/main/java/com/linecorp/armeria/xds/XdsExtensionRegistry.java
+++ b/xds/src/main/java/com/linecorp/armeria/xds/XdsExtensionRegistry.java
@@ -77,6 +77,7 @@ static XdsExtensionRegistry of(XdsResourceValidator validator,
// Built-in transport socket factories
register(UpstreamTlsTransportSocketFactory.INSTANCE, byName, byTypeUrl);
+ register(DownstreamTlsTransportSocketFactory.INSTANCE, byName, byTypeUrl);
register(RawBufferTransportSocketFactory.INSTANCE, byName, byTypeUrl);
return new XdsExtensionRegistry(byTypeUrl.build(), byName.build(), validator);
diff --git a/xds/src/main/java/com/linecorp/armeria/xds/XdsServerPlugin.java b/xds/src/main/java/com/linecorp/armeria/xds/XdsServerPlugin.java
new file mode 100644
index 00000000000..81231541f6e
--- /dev/null
+++ b/xds/src/main/java/com/linecorp/armeria/xds/XdsServerPlugin.java
@@ -0,0 +1,233 @@
+/*
+ * Copyright 2025 LINE Corporation
+ *
+ * LINE Corporation licenses this file to you 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:
+ *
+ * https://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 com.linecorp.armeria.xds;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.util.Objects.requireNonNull;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import com.google.common.collect.ImmutableList;
+
+import com.linecorp.armeria.common.HttpRequest;
+import com.linecorp.armeria.common.HttpResponse;
+import com.linecorp.armeria.common.SessionProtocol;
+import com.linecorp.armeria.common.annotation.UnstableApi;
+import com.linecorp.armeria.common.util.UnmodifiableFuture;
+import com.linecorp.armeria.server.ConnectionAcceptor;
+import com.linecorp.armeria.server.ConnectionContext;
+import com.linecorp.armeria.server.HttpService;
+import com.linecorp.armeria.server.Server;
+import com.linecorp.armeria.server.ServerBuilder;
+import com.linecorp.armeria.server.ServerPlugin;
+import com.linecorp.armeria.server.ServerPort;
+import com.linecorp.armeria.server.ServerTlsProvider;
+import com.linecorp.armeria.server.ServiceConfig;
+import com.linecorp.armeria.server.ServiceRequestContext;
+import com.linecorp.armeria.server.SimpleDecoratingHttpService;
+import com.linecorp.armeria.xds.ServerSnapshotWatcher.ResolvedFilterChain;
+
+/**
+ * A {@link ServerPlugin} that integrates xDS-based connection configuration into an Armeria
+ * {@link Server}. This plugin subscribes to a {@link ListenerRoot} via a
+ * {@link ServerSnapshotWatcher} and encapsulates the wiring of TLS provider and server
+ * decorator registration.
+ *
+ * A single plugin can listen on multiple ports. The xDS filter chain matching
+ * (which uses {@code destination_port}) handles per-port differentiation.
+ *
+ *
Example usage:
+ *
{@code
+ * Server.builder()
+ * .addPlugin(new XdsServerPlugin(xdsBootstrap, "listener", 8080, 8443))
+ * .service("/api", myService)
+ * .build();
+ * }
+ */
+@UnstableApi
+public final class XdsServerPlugin implements ServerPlugin {
+
+ private static final Duration DEFAULT_READY_TIMEOUT = Duration.ofSeconds(30);
+
+ private final ListenerRoot listenerRoot;
+ private final ServerSnapshotWatcher watcher;
+ private final List serverPorts;
+ private final Duration readyTimeout;
+
+ /**
+ * Creates a new {@link XdsServerPlugin} that subscribes to the given listener
+ * and listens on an ephemeral port with HTTP and HTTPS.
+ */
+ public XdsServerPlugin(XdsBootstrap bootstrap, String listenerName) {
+ this(bootstrap, listenerName,
+ ImmutableList.of(new ServerPort(0, SessionProtocol.HTTP, SessionProtocol.HTTPS)),
+ DEFAULT_READY_TIMEOUT);
+ }
+
+ /**
+ * Creates a new {@link XdsServerPlugin} that subscribes to the given listener
+ * and listens on the specified port(s) with HTTP and HTTPS.
+ */
+ public XdsServerPlugin(XdsBootstrap bootstrap, String listenerName, int... ports) {
+ this(bootstrap, listenerName, toServerPorts(ports), DEFAULT_READY_TIMEOUT);
+ }
+
+ /**
+ * Creates a new {@link XdsServerPlugin} that subscribes to the given listener
+ * and listens on the specified {@link ServerPort}.
+ */
+ public XdsServerPlugin(XdsBootstrap bootstrap, String listenerName, ServerPort serverPort) {
+ this(bootstrap, listenerName, ImmutableList.of(serverPort), DEFAULT_READY_TIMEOUT);
+ }
+
+ /**
+ * Creates a new {@link XdsServerPlugin} that subscribes to the given listener
+ * and listens on the specified {@link ServerPort}, waiting up to {@code readyTimeout}
+ * for the first xDS snapshot to be resolved before the server starts.
+ */
+ public XdsServerPlugin(XdsBootstrap bootstrap, String listenerName, ServerPort serverPort,
+ Duration readyTimeout) {
+ this(bootstrap, listenerName, ImmutableList.of(serverPort), readyTimeout);
+ }
+
+ private XdsServerPlugin(XdsBootstrap bootstrap, String listenerName,
+ List serverPorts, Duration readyTimeout) {
+ requireNonNull(bootstrap, "bootstrap");
+ requireNonNull(listenerName, "listenerName");
+ requireNonNull(readyTimeout, "readyTimeout");
+ checkArgument(!readyTimeout.isNegative(), "readyTimeout: %s (expected: >= 0)", readyTimeout);
+ checkArgument(!serverPorts.isEmpty(), "At least one port must be specified.");
+ this.listenerRoot = bootstrap.listenerRoot(listenerName);
+ this.watcher = new ServerSnapshotWatcher();
+ this.listenerRoot.addSnapshotWatcher(watcher);
+ this.serverPorts = serverPorts;
+ this.readyTimeout = readyTimeout;
+ }
+
+ private static List toServerPorts(int... ports) {
+ checkArgument(ports.length > 0, "At least one port must be specified.");
+ final ImmutableList.Builder builder = ImmutableList.builder();
+ for (int port : ports) {
+ builder.add(new ServerPort(port, SessionProtocol.HTTP, SessionProtocol.HTTPS));
+ }
+ return builder.build();
+ }
+
+ @Override
+ public void install(ServerBuilder sb) {
+ // Block until the first xDS snapshot is resolved so TLS and decorators
+ // are available before the server is built.
+ try {
+ watcher.whenReady().get(readyTimeout.toMillis(), TimeUnit.MILLISECONDS);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ for (ServerPort serverPort : serverPorts) {
+ sb.port(serverPort);
+ }
+ final ConnectionAcceptor existingAcceptor = sb.connectionAcceptor();
+ sb.connectionAcceptor(ctx -> {
+ // Only apply xDS policy to connections on xDS-managed ports.
+ // actualPort() resolves ephemeral ports (0) to the real bound port.
+ final int port = ctx.localAddress().getPort();
+ boolean managed = false;
+ for (ServerPort sp : serverPorts) {
+ if (sp.actualPort() == port) {
+ managed = true;
+ break;
+ }
+ }
+ if (!managed) {
+ // Defer to existing acceptor if present, otherwise accept.
+ if (existingAcceptor != null) {
+ return existingAcceptor.accept(ctx);
+ }
+ return UnmodifiableFuture.completedFuture(true);
+ }
+ final ResolvedFilterChain matched = watcher.match(ctx);
+ if (matched != null) {
+ ctx.setAttr(ServerSnapshotWatcher.MATCHED_FILTER_CHAIN, matched);
+ if (matched.maxConnectionDurationMillis() > 0) {
+ ctx.setMaxConnectionAgeMillis(matched.maxConnectionDurationMillis());
+ }
+ return UnmodifiableFuture.completedFuture(true);
+ }
+ return UnmodifiableFuture.completedFuture(false);
+ });
+ final ServerTlsProvider existingTlsProvider = sb.tlsProvider();
+ sb.tlsProvider((ServerTlsProvider) ctx -> {
+ final ResolvedFilterChain matched =
+ ctx.attr(ServerSnapshotWatcher.MATCHED_FILTER_CHAIN);
+ if (matched != null && matched.serverTlsSpec() != null) {
+ return UnmodifiableFuture.completedFuture(matched.serverTlsSpec());
+ }
+ if (existingTlsProvider != null) {
+ return existingTlsProvider.serverTlsSpec(ctx);
+ }
+ return UnmodifiableFuture.completedFuture(null);
+ });
+ sb.decorator(delegate -> new XdsRootDecorator(delegate, watcher, serverPorts));
+ }
+
+ @Override
+ public void close() {
+ listenerRoot.close();
+ }
+
+ private static final class XdsRootDecorator extends SimpleDecoratingHttpService {
+
+ private final ServerSnapshotWatcher watcher;
+ private final List serverPorts;
+
+ XdsRootDecorator(HttpService delegate, ServerSnapshotWatcher watcher,
+ List serverPorts) {
+ super(delegate);
+ this.watcher = watcher;
+ this.serverPorts = serverPorts;
+ }
+
+ @Override
+ public void serviceAdded(ServiceConfig cfg) throws Exception {
+ super.serviceAdded(cfg);
+ watcher.setServiceConfig(cfg);
+ }
+
+ @Override
+ public HttpResponse serve(ServiceRequestContext ctx, HttpRequest req) throws Exception {
+ final ConnectionContext connCtx = ctx.connectionContext();
+ if (connCtx != null && isManagedPort(connCtx.localAddress().getPort())) {
+ final ResolvedFilterChain matched =
+ connCtx.attr(ServerSnapshotWatcher.MATCHED_FILTER_CHAIN);
+ if (matched != null && matched.decorator() != null) {
+ DelegatingHttpService.setDelegate(ctx, (HttpService) unwrap());
+ return matched.decorator().serve(ctx, req);
+ }
+ }
+ return unwrap().serve(ctx, req);
+ }
+
+ private boolean isManagedPort(int port) {
+ for (ServerPort sp : serverPorts) {
+ if (sp.actualPort() == port) {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+}
diff --git a/xds/src/main/java/com/linecorp/armeria/xds/filter/XdsHttpFilter.java b/xds/src/main/java/com/linecorp/armeria/xds/filter/XdsHttpFilter.java
index a4066ea7168..f70a6c48b06 100644
--- a/xds/src/main/java/com/linecorp/armeria/xds/filter/XdsHttpFilter.java
+++ b/xds/src/main/java/com/linecorp/armeria/xds/filter/XdsHttpFilter.java
@@ -16,6 +16,8 @@
package com.linecorp.armeria.xds.filter;
+import java.util.function.Function;
+
import com.linecorp.armeria.client.DecoratingHttpClientFunction;
import com.linecorp.armeria.client.DecoratingRpcClientFunction;
import com.linecorp.armeria.client.HttpClient;
@@ -24,6 +26,7 @@
import com.linecorp.armeria.client.RpcClient;
import com.linecorp.armeria.client.RpcPreprocessor;
import com.linecorp.armeria.common.annotation.UnstableApi;
+import com.linecorp.armeria.server.HttpService;
/**
* Represents a resolved HTTP filter returned by {@link HttpFilterFactory#create}.
@@ -65,4 +68,11 @@ default DecoratingHttpClientFunction httpDecorator() {
default DecoratingRpcClientFunction rpcDecorator() {
return RpcClient::execute;
}
+
+ /**
+ * Returns a server-side decorator for this filter.
+ */
+ default Function super HttpService, ? extends HttpService> serverDecorator() {
+ return Function.identity();
+ }
}