|
| 1 | +/* |
| 2 | + * Copyright 2026 LY Corporation |
| 3 | + * |
| 4 | + * LY Corporation licenses this file to you under the Apache License, |
| 5 | + * version 2.0 (the "License"); you may not use this file except in compliance |
| 6 | + * with the License. You may obtain a copy of the License at: |
| 7 | + * |
| 8 | + * https://www.apache.org/licenses/LICENSE-2.0 |
| 9 | + * |
| 10 | + * Unless required by applicable law or agreed to in writing, software |
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| 12 | + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| 13 | + * License for the specific language governing permissions and limitations |
| 14 | + * under the License. |
| 15 | + */ |
| 16 | +package com.linecorp.armeria.xds.it.server; |
| 17 | + |
| 18 | +import static org.assertj.core.api.Assertions.assertThat; |
| 19 | +import static org.assertj.core.api.Assertions.assertThatThrownBy; |
| 20 | + |
| 21 | +import java.nio.file.Path; |
| 22 | +import java.util.concurrent.atomic.AtomicInteger; |
| 23 | + |
| 24 | +import javax.net.ssl.SSLHandshakeException; |
| 25 | + |
| 26 | +import org.junit.jupiter.api.Order; |
| 27 | +import org.junit.jupiter.api.Test; |
| 28 | +import org.junit.jupiter.api.extension.RegisterExtension; |
| 29 | + |
| 30 | +import com.linecorp.armeria.client.BlockingWebClient; |
| 31 | +import com.linecorp.armeria.client.ClientTlsSpec; |
| 32 | +import com.linecorp.armeria.client.RequestOptions; |
| 33 | +import com.linecorp.armeria.client.WebClient; |
| 34 | +import com.linecorp.armeria.common.AggregatedHttpResponse; |
| 35 | +import com.linecorp.armeria.common.HttpMethod; |
| 36 | +import com.linecorp.armeria.common.HttpRequest; |
| 37 | +import com.linecorp.armeria.common.HttpResponse; |
| 38 | +import com.linecorp.armeria.common.HttpStatus; |
| 39 | +import com.linecorp.armeria.common.SessionProtocol; |
| 40 | +import com.linecorp.armeria.server.ConnectionAcceptor; |
| 41 | +import com.linecorp.armeria.server.ServerBuilder; |
| 42 | +import com.linecorp.armeria.server.ServerPort; |
| 43 | +import com.linecorp.armeria.server.ServerTlsProvider; |
| 44 | +import com.linecorp.armeria.server.ServerTlsSpec; |
| 45 | +import com.linecorp.armeria.testing.junit5.server.SelfSignedCertificateExtension; |
| 46 | +import com.linecorp.armeria.testing.junit5.server.ServerExtension; |
| 47 | +import com.linecorp.armeria.xds.it.XdsCertificateExtension; |
| 48 | +import com.linecorp.armeria.xds.it.XdsControlPlaneExtension; |
| 49 | +import com.linecorp.armeria.xds.it.XdsResourceReader; |
| 50 | +import com.linecorp.armeria.xds.server.XdsServerPlugin; |
| 51 | + |
| 52 | +import io.envoyproxy.envoy.config.listener.v3.Listener; |
| 53 | + |
| 54 | +/** |
| 55 | + * Verifies that {@link XdsServerPlugin} correctly composes with user-configured |
| 56 | + * {@link ConnectionAcceptor} and {@link ServerTlsProvider} on both xDS-managed |
| 57 | + * and unmanaged ports. |
| 58 | + */ |
| 59 | +class ServerFallbackTest { |
| 60 | + |
| 61 | + private static final String LISTENER_NAME = "fallback-listener"; |
| 62 | + private static final ServerPort xdsPort = |
| 63 | + new ServerPort(0, SessionProtocol.HTTP, SessionProtocol.HTTPS); |
| 64 | + |
| 65 | + private static final AtomicInteger acceptorCallCount = new AtomicInteger(); |
| 66 | + |
| 67 | + @RegisterExtension |
| 68 | + @Order(0) |
| 69 | + static final XdsCertificateExtension xdsCert = |
| 70 | + new XdsCertificateExtension(new SelfSignedCertificateExtension("127.0.0.1")); |
| 71 | + |
| 72 | + @RegisterExtension |
| 73 | + @Order(1) |
| 74 | + static final SelfSignedCertificateExtension userCert = |
| 75 | + new SelfSignedCertificateExtension("127.0.0.1"); |
| 76 | + |
| 77 | + @RegisterExtension |
| 78 | + @Order(2) |
| 79 | + static final XdsControlPlaneExtension controlPlane = new XdsControlPlaneExtension(); |
| 80 | + |
| 81 | + @RegisterExtension |
| 82 | + @Order(3) |
| 83 | + static final ServerExtension server = new ServerExtension() { |
| 84 | + @Override |
| 85 | + protected void configure(ServerBuilder sb) { |
| 86 | + final Path certPath = xdsCert.certificateFile().toPath(); |
| 87 | + final Path keyPath = xdsCert.privateKeyFile().toPath(); |
| 88 | + |
| 89 | + //language=YAML |
| 90 | + final String yaml = |
| 91 | + """ |
| 92 | + name: %s |
| 93 | + default_filter_chain: |
| 94 | + filters: |
| 95 | + - name: envoy.filters.network.http_connection_manager |
| 96 | + typed_config: |
| 97 | + "@type": type.googleapis.com/envoy.extensions.filters\ |
| 98 | + .network.http_connection_manager.v3.HttpConnectionManager |
| 99 | + stat_prefix: ingress_http |
| 100 | + route_config: |
| 101 | + name: local_route |
| 102 | + virtual_hosts: |
| 103 | + - name: local_service |
| 104 | + domains: ["*"] |
| 105 | + routes: |
| 106 | + - match: |
| 107 | + prefix: "/" |
| 108 | + non_forwarding_action: {} |
| 109 | + http_filters: |
| 110 | + - name: envoy.filters.http.router |
| 111 | + transport_socket: |
| 112 | + name: envoy.transport_sockets.downstream_tls |
| 113 | + typed_config: |
| 114 | + "@type": type.googleapis.com/envoy.extensions.transport_sockets\ |
| 115 | + .tls.v3.DownstreamTlsContext |
| 116 | + common_tls_context: |
| 117 | + tls_certificates: |
| 118 | + - certificate_chain: |
| 119 | + filename: "%s" |
| 120 | + private_key: |
| 121 | + filename: "%s" |
| 122 | + """.formatted(LISTENER_NAME, certPath, keyPath); |
| 123 | + controlPlane.set(XdsResourceReader.fromYaml(yaml, Listener.class)); |
| 124 | + |
| 125 | + // User-configured ConnectionAcceptor — should be called on all ports. |
| 126 | + sb.connectionAcceptor(ConnectionAcceptor.of(ctx -> { |
| 127 | + acceptorCallCount.incrementAndGet(); |
| 128 | + return true; |
| 129 | + })); |
| 130 | + |
| 131 | + // User-configured ServerTlsProvider for the unmanaged HTTPS port. |
| 132 | + final ServerTlsSpec userTlsSpec = ServerTlsSpec.builder() |
| 133 | + .tlsKeyPair(userCert.tlsKeyPair()) |
| 134 | + .build(); |
| 135 | + sb.tlsProvider(ServerTlsProvider.of(ctx -> userTlsSpec)); |
| 136 | + |
| 137 | + // Add an explicit unmanaged HTTPS port (not controlled by xDS). |
| 138 | + sb.https(0); |
| 139 | + |
| 140 | + sb.plugin(XdsServerPlugin.builder(controlPlane.bootstrap(), LISTENER_NAME) |
| 141 | + .port(xdsPort) |
| 142 | + .build()); |
| 143 | + sb.service("/hello", (ctx, req) -> HttpResponse.of("hello")); |
| 144 | + } |
| 145 | + }; |
| 146 | + |
| 147 | + @Test |
| 148 | + void connectionAcceptorCalledOnManagedPort() { |
| 149 | + acceptorCallCount.set(0); |
| 150 | + final ClientTlsSpec tlsSpec = ClientTlsSpec.builder() |
| 151 | + .trustedCertificates(xdsCert.certificate()) |
| 152 | + .build(); |
| 153 | + final BlockingWebClient client = |
| 154 | + WebClient.of("https://127.0.0.1:" + xdsPort.actualPort()).blocking(); |
| 155 | + final AggregatedHttpResponse res = client.execute( |
| 156 | + HttpRequest.of(HttpMethod.GET, "/hello"), |
| 157 | + RequestOptions.builder().clientTlsSpec(tlsSpec).build()); |
| 158 | + assertThat(res.status()).isEqualTo(HttpStatus.OK); |
| 159 | + assertThat(res.contentUtf8()).isEqualTo("hello"); |
| 160 | + assertThat(acceptorCallCount.get()).isGreaterThanOrEqualTo(1); |
| 161 | + } |
| 162 | + |
| 163 | + @Test |
| 164 | + void connectionAcceptorCalledOnUnmanagedPort() { |
| 165 | + acceptorCallCount.set(0); |
| 166 | + final AggregatedHttpResponse res = |
| 167 | + WebClient.of(server.httpUri()).blocking().get("/hello"); |
| 168 | + assertThat(res.status()).isEqualTo(HttpStatus.OK); |
| 169 | + assertThat(res.contentUtf8()).isEqualTo("hello"); |
| 170 | + assertThat(acceptorCallCount.get()).isGreaterThanOrEqualTo(1); |
| 171 | + } |
| 172 | + |
| 173 | + @Test |
| 174 | + void tlsProviderFallbackOnUnmanagedPort() { |
| 175 | + // The unmanaged HTTPS port should use the user-configured TLS provider (userCert), |
| 176 | + // not the xDS certificate. |
| 177 | + final int unmanagedHttpsPort = server.server().activePorts().values().stream() |
| 178 | + .filter(ServerPort::hasHttps) |
| 179 | + .map(p -> p.localAddress().getPort()) |
| 180 | + .filter(p -> p != xdsPort.actualPort()) |
| 181 | + .findFirst() |
| 182 | + .orElseThrow(); |
| 183 | + final ClientTlsSpec userTlsSpec = ClientTlsSpec.builder() |
| 184 | + .trustedCertificates(userCert.certificate()) |
| 185 | + .build(); |
| 186 | + final BlockingWebClient client = |
| 187 | + WebClient.of("https://127.0.0.1:" + unmanagedHttpsPort).blocking(); |
| 188 | + final AggregatedHttpResponse res = client.execute( |
| 189 | + HttpRequest.of(HttpMethod.GET, "/hello"), |
| 190 | + RequestOptions.builder().clientTlsSpec(userTlsSpec).build()); |
| 191 | + assertThat(res.status()).isEqualTo(HttpStatus.OK); |
| 192 | + assertThat(res.contentUtf8()).isEqualTo("hello"); |
| 193 | + } |
| 194 | + |
| 195 | + @Test |
| 196 | + void xdsCertNotTrustedOnUnmanagedPort() { |
| 197 | + // The xDS certificate should NOT work on the unmanaged port — that port uses |
| 198 | + // userCert, so trusting xdsCert should fail the TLS handshake. |
| 199 | + final int unmanagedHttpsPort = server.server().activePorts().values().stream() |
| 200 | + .filter(ServerPort::hasHttps) |
| 201 | + .map(p -> p.localAddress().getPort()) |
| 202 | + .filter(p -> p != xdsPort.actualPort()) |
| 203 | + .findFirst() |
| 204 | + .orElseThrow(); |
| 205 | + final ClientTlsSpec xdsTlsSpec = ClientTlsSpec.builder() |
| 206 | + .trustedCertificates(xdsCert.certificate()) |
| 207 | + .build(); |
| 208 | + final BlockingWebClient client = |
| 209 | + WebClient.of("https://127.0.0.1:" + unmanagedHttpsPort).blocking(); |
| 210 | + assertThatThrownBy(() -> client.execute( |
| 211 | + HttpRequest.of(HttpMethod.GET, "/hello"), |
| 212 | + RequestOptions.builder().clientTlsSpec(xdsTlsSpec).build())) |
| 213 | + .hasCauseInstanceOf(SSLHandshakeException.class); |
| 214 | + } |
| 215 | +} |
0 commit comments