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 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 bossGroupFactory; + @Nullable + private final ConnectionAcceptor connectionAcceptor; @Nullable private String strVal; @@ -163,13 +167,15 @@ final class DefaultServerConfig implements ServerConfig { Function clientAddressMapper, boolean enableServerHeader, boolean enableDateHeader, ServerErrorHandler errorHandler, - @Nullable Mapping sslContexts, + @Nullable ServerTlsProvider serverTlsProvider, + @Nullable SslContextFactory sslContextFactory, Http1HeaderNaming http1HeaderNaming, DependencyInjector dependencyInjector, Function absoluteUriTransformer, long unloggedExceptionsReportIntervalMillis, List shutdownSupports, - @Nullable Function bossGroupFactory) { + @Nullable Function 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 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: + *

    + *
  1. {@link ServerTlsProvider} if set via {@code tlsProvider(ServerTlsProvider)}
  2. + *
  3. {@link TlsProvider} (wrapped in {@link TlsProviderAdapter}) if set via + * {@code tlsProvider(TlsProvider)}
  4. + *
  5. {@link StaticTlsProvider} from VirtualHost {@code tls()} settings otherwise
  6. + *
+ */ + 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 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 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: + *

+ */ +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 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 configuratorClass; + @Nullable + private final Consumer deploymentCustomizer; public IstioServerExtension(String serviceName, int port, Class configuratorClass) { + this(serviceName, port, configuratorClass, null); + } + + public IstioServerExtension(String serviceName, int port, + Class 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 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 serverDecorator; + private final long maxConnectionDurationMillis; + + ParsedFilterChain(FilterChain filterChain, + @Nullable Function 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 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 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 serverDecorator() { + return Function.identity(); + } }