From 8d2c7b5c0e7f44b3c73912eaaef39a178e7b9722 Mon Sep 17 00:00:00 2001 From: jrhee17 Date: Thu, 2 Jul 2026 15:48:21 +0900 Subject: [PATCH 1/6] minimal impl --- .../it/server/ServerFilterChainMatchTest.java | 328 ++++++++++++++++++ .../it/server/ServerTlsSpecSelectorTest.java | 189 ++++++++++ .../listener/v3/listener_components.proto | 4 + .../armeria/xds/FilterChainMatcher.java | 250 +++++++++++++ .../armeria/xds/ListenerSnapshot.java | 8 +- .../armeria/xds/ServerTlsSpecSelector.java | 146 ++++++++ .../armeria/xds/TransportSocketSnapshot.java | 14 +- 7 files changed, 928 insertions(+), 11 deletions(-) create mode 100644 it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerFilterChainMatchTest.java create mode 100644 it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerTlsSpecSelectorTest.java create mode 100644 xds/src/main/java/com/linecorp/armeria/xds/FilterChainMatcher.java create mode 100644 xds/src/main/java/com/linecorp/armeria/xds/ServerTlsSpecSelector.java diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerFilterChainMatchTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerFilterChainMatchTest.java new file mode 100644 index 00000000000..686c0639913 --- /dev/null +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerFilterChainMatchTest.java @@ -0,0 +1,328 @@ +/* + * 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.xds.it.server; + +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 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.testing.junit5.server.SelfSignedCertificateExtension; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; +import com.linecorp.armeria.xds.it.XdsCertificateExtension; +import com.linecorp.armeria.xds.it.XdsControlPlaneExtension; +import com.linecorp.armeria.xds.it.XdsResourceReader; +import com.linecorp.armeria.xds.server.XdsServerPlugin; + +import io.envoyproxy.envoy.config.listener.v3.Listener; + +class ServerFilterChainMatchTest { + + private static final String LISTENER_NAME = "filter-chain-listener"; + + @RegisterExtension + @Order(0) + static final XdsCertificateExtension certA = + new XdsCertificateExtension(new SelfSignedCertificateExtension("127.0.0.1")); + + @RegisterExtension + @Order(1) + static final XdsCertificateExtension certDefault = + new XdsCertificateExtension(new SelfSignedCertificateExtension("127.0.0.1")); + + // A cert that no server chain ever presents — used only for negative assertions. + @RegisterExtension + @Order(2) + static final XdsCertificateExtension certWrong = + new XdsCertificateExtension(new SelfSignedCertificateExtension("127.0.0.1")); + + @RegisterExtension + @Order(3) + static final XdsControlPlaneExtension controlPlane = new XdsControlPlaneExtension(); + + @RegisterExtension + @Order(4) + static final ServerExtension server = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + // Push a minimal listener to unblock XdsServerPlugin.install(). + controlPlane.set(Listener.newBuilder().setName(LISTENER_NAME).build()); + sb.plugin(XdsServerPlugin.of(controlPlane.bootstrap(), LISTENER_NAME)); + sb.service("/hello", (ctx, req) -> HttpResponse.of("hello")); + } + }; + + @Test + void matchByTransportProtocol() { + final Path certPathA = certA.certificateFile().toPath(); + final Path keyPathA = certA.privateKeyFile().toPath(); + + //language=YAML + final String yaml = + """ + name: %s + filter_chains: + - filter_chain_match: + transport_protocol: "tls" + 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: "/" + non_forwarding_action: {} + http_filters: + - name: envoy.filters.http.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" + - filter_chain_match: + transport_protocol: "raw_buffer" + 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: "/" + non_forwarding_action: {} + http_filters: + - name: envoy.filters.http.router + """.formatted(LISTENER_NAME, certPathA, keyPathA); + final String ver = controlPlane.set(XdsResourceReader.fromYaml(yaml, Listener.class)); + controlPlane.awaitListener(LISTENER_NAME, ver); + + // HTTPS connection should match the "tls" chain and present certA. + final ClientTlsSpec tlsSpec = ClientTlsSpec.builder() + .trustedCertificates(certA.certificate()) + .build(); + final BlockingWebClient httpsClient = + WebClient.of(server.httpsUri()).blocking(); + final AggregatedHttpResponse httpsRes = httpsClient.execute( + HttpRequest.of(HttpMethod.GET, "/hello"), + RequestOptions.builder().clientTlsSpec(tlsSpec).build()); + assertThat(httpsRes.status()).isEqualTo(HttpStatus.OK); + assertThat(httpsRes.contentUtf8()).isEqualTo("hello"); + + // HTTP (plaintext) connection should match the "raw_buffer" chain. + final AggregatedHttpResponse httpRes = + WebClient.of(server.httpUri()).blocking().get("/hello"); + assertThat(httpRes.status()).isEqualTo(HttpStatus.OK); + assertThat(httpRes.contentUtf8()).isEqualTo("hello"); + } + + @Test + void defaultFilterChainFallback() { + final Path certPathA = certA.certificateFile().toPath(); + final Path keyPathA = certA.privateKeyFile().toPath(); + final Path certPathDefault = certDefault.certificateFile().toPath(); + final Path keyPathDefault = certDefault.privateKeyFile().toPath(); + + // Chain A requires server_names=["no-such-host.example.com"] — will never match. + // Default chain has certDefault — should always be used. + //language=YAML + final String yaml = + """ + name: %s + filter_chains: + - filter_chain_match: + server_names: + - "no-such-host.example.com" + transport_protocol: "tls" + 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: "/" + non_forwarding_action: {} + http_filters: + - name: envoy.filters.http.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" + 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: "/" + non_forwarding_action: {} + http_filters: + - name: envoy.filters.http.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" + """.formatted(LISTENER_NAME, certPathA, keyPathA, certPathDefault, keyPathDefault); + final String ver = controlPlane.set(XdsResourceReader.fromYaml(yaml, Listener.class)); + controlPlane.awaitListener(LISTENER_NAME, ver); + + // The specific chain requires server_names=["no-such-host.example.com"] + // which won't match. The default chain with certDefault should be used. + final ClientTlsSpec defaultTlsSpec = ClientTlsSpec.builder() + .trustedCertificates(certDefault.certificate()) + .build(); + final BlockingWebClient defaultClient = WebClient.of(server.httpsUri()).blocking(); + final AggregatedHttpResponse res = defaultClient.execute( + HttpRequest.of(HttpMethod.GET, "/hello"), + RequestOptions.builder().clientTlsSpec(defaultTlsSpec).build()); + assertThat(res.status()).isEqualTo(HttpStatus.OK); + assertThat(res.contentUtf8()).isEqualTo("hello"); + + // certWrong should NOT work since the server presents certDefault. + final ClientTlsSpec wrongTlsSpec = ClientTlsSpec.builder() + .trustedCertificates(certWrong.certificate()) + .build(); + final BlockingWebClient wrongClient = WebClient.of(server.httpsUri()).blocking(); + assertThatThrownBy(() -> wrongClient.execute( + HttpRequest.of(HttpMethod.GET, "/hello"), + RequestOptions.builder().clientTlsSpec(wrongTlsSpec).build())) + .isInstanceOf(UnprocessedRequestException.class) + .hasRootCauseInstanceOf(SignatureException.class); + } + + @Test + void unmatchedConnectionRejected() { + final Path certPathA = certA.certificateFile().toPath(); + final Path keyPathA = certA.privateKeyFile().toPath(); + + // Only chain matches server_names=["no-such-host.example.com"], no default chain. + //language=YAML + final String yaml = + """ + name: %s + filter_chains: + - filter_chain_match: + server_names: + - "no-such-host.example.com" + transport_protocol: "tls" + 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: "/" + non_forwarding_action: {} + http_filters: + - name: envoy.filters.http.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" + """.formatted(LISTENER_NAME, certPathA, keyPathA); + final String ver = controlPlane.set(XdsResourceReader.fromYaml(yaml, Listener.class)); + controlPlane.awaitListener(LISTENER_NAME, ver); + + // No chain matches (server_names don't match), no default chain. + // Connection should be rejected. + final ClientTlsSpec tlsSpec = ClientTlsSpec.builder() + .trustedCertificates(certWrong.certificate()) + .build(); + final BlockingWebClient client = WebClient.of(server.httpsUri()).blocking(); + assertThatThrownBy(() -> client.execute( + HttpRequest.of(HttpMethod.GET, "/hello"), + RequestOptions.builder().clientTlsSpec(tlsSpec).build())) + .isInstanceOf(UnprocessedRequestException.class); + } +} diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerTlsSpecSelectorTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerTlsSpecSelectorTest.java new file mode 100644 index 00000000000..7f179c34470 --- /dev/null +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerTlsSpecSelectorTest.java @@ -0,0 +1,189 @@ +/* + * 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.xds.it.server; + +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.BlockingWebClient; +import com.linecorp.armeria.client.ClientTlsSpec; +import com.linecorp.armeria.client.Endpoint; +import com.linecorp.armeria.client.RequestOptions; +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.common.SessionProtocol; +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.it.XdsCertificateExtension; +import com.linecorp.armeria.xds.it.XdsControlPlaneExtension; +import com.linecorp.armeria.xds.it.XdsResourceReader; +import com.linecorp.armeria.xds.server.XdsServerPlugin; + +import io.envoyproxy.envoy.config.listener.v3.Listener; + +class ServerTlsSpecSelectorTest { + + private static final String LISTENER_NAME = "sni-listener"; + + @RegisterExtension + @Order(0) + static final XdsCertificateExtension certFoo = + new XdsCertificateExtension(new SelfSignedCertificateExtension("foo.example.com")); + + @RegisterExtension + @Order(1) + static final XdsCertificateExtension certBar = + new XdsCertificateExtension(new SelfSignedCertificateExtension("bar.example.com")); + + @RegisterExtension + @Order(2) + static final XdsControlPlaneExtension controlPlane = new XdsControlPlaneExtension(); + + @RegisterExtension + @Order(3) + static final ServerExtension server = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + final Path certPathFoo = certFoo.certificateFile().toPath(); + final Path keyPathFoo = certFoo.privateKeyFile().toPath(); + final Path certPathBar = certBar.certificateFile().toPath(); + final Path keyPathBar = certBar.privateKeyFile().toPath(); + + //language=YAML + final String yaml = + """ + name: %s + 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: "/" + non_forwarding_action: {} + http_filters: + - name: envoy.filters.http.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" + - certificate_chain: + filename: "%s" + private_key: + filename: "%s" + """.formatted(LISTENER_NAME, certPathFoo, keyPathFoo, certPathBar, keyPathBar); + controlPlane.set(XdsResourceReader.fromYaml(yaml, Listener.class)); + sb.plugin(XdsServerPlugin.of(controlPlane.bootstrap(), LISTENER_NAME)); + sb.service("/hello", (ctx, req) -> HttpResponse.of("hello")); + } + }; + + @Test + void exactSniMatch() { + final int port = server.httpsPort(); + + // SNI "foo.example.com" → certFoo should be presented. + // Trust only certFoo: if certBar were presented, the handshake would fail. + final ClientTlsSpec fooTlsSpec = ClientTlsSpec.builder() + .trustedCertificates(certFoo.certificate()) + .build(); + final Endpoint fooEndpoint = Endpoint.of("foo.example.com", port).withIpAddr("127.0.0.1"); + final BlockingWebClient fooClient = + WebClient.builder(SessionProtocol.HTTPS, fooEndpoint).build().blocking(); + final AggregatedHttpResponse fooRes = fooClient.execute( + HttpRequest.of(HttpMethod.GET, "/hello"), + RequestOptions.builder().clientTlsSpec(fooTlsSpec).build()); + assertThat(fooRes.status()).isEqualTo(HttpStatus.OK); + assertThat(fooRes.contentUtf8()).isEqualTo("hello"); + + // SNI "bar.example.com" → certBar should be presented. + // Trust only certBar: if certFoo were presented, the handshake would fail. + final ClientTlsSpec barTlsSpec = ClientTlsSpec.builder() + .trustedCertificates(certBar.certificate()) + .build(); + final Endpoint barEndpoint = Endpoint.of("bar.example.com", port).withIpAddr("127.0.0.1"); + final BlockingWebClient barClient = + WebClient.builder(SessionProtocol.HTTPS, barEndpoint).build().blocking(); + final AggregatedHttpResponse barRes = barClient.execute( + HttpRequest.of(HttpMethod.GET, "/hello"), + RequestOptions.builder().clientTlsSpec(barTlsSpec).build()); + assertThat(barRes.status()).isEqualTo(HttpStatus.OK); + assertThat(barRes.contentUtf8()).isEqualTo("hello"); + } + + @Test + void fallbackToFirstCert() { + // Unknown SNI → first cert (certFoo) should be presented. + // Trust only certFoo, disable hostname verification since the cert CN + // (foo.example.com) won't match the SNI (unknown.example.com). + final ClientTlsSpec tlsSpec = ClientTlsSpec.builder() + .trustedCertificates(certFoo.certificate()) + .endpointIdentificationAlgorithm("") + .build(); + final int port = server.httpsPort(); + final Endpoint endpoint = Endpoint.of("unknown.example.com", port).withIpAddr("127.0.0.1"); + final BlockingWebClient client = + WebClient.builder(SessionProtocol.HTTPS, endpoint).build().blocking(); + final AggregatedHttpResponse res = client.execute( + HttpRequest.of(HttpMethod.GET, "/hello"), + RequestOptions.builder().clientTlsSpec(tlsSpec).build()); + assertThat(res.status()).isEqualTo(HttpStatus.OK); + assertThat(res.contentUtf8()).isEqualTo("hello"); + } + + @Test + void noSniReturnsFirstCert() { + // Connect by IP (127.0.0.1) — no SNI hostname is sent. + // Server should present the first cert (certFoo). + // Disable hostname verification since the cert CN (foo.example.com) won't match 127.0.0.1. + final ClientTlsSpec tlsSpec = ClientTlsSpec.builder() + .trustedCertificates(certFoo.certificate()) + .endpointIdentificationAlgorithm("") + .build(); + final BlockingWebClient client = WebClient.of(server.httpsUri()).blocking(); + final AggregatedHttpResponse res = client.execute( + HttpRequest.of(HttpMethod.GET, "/hello"), + RequestOptions.builder().clientTlsSpec(tlsSpec).build()); + assertThat(res.status()).isEqualTo(HttpStatus.OK); + assertThat(res.contentUtf8()).isEqualTo("hello"); + } +} 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 1f94ae1906a..d53d6784b5a 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 @@ -115,6 +115,7 @@ message FilterChainMatch { // Optional destination port to consider when use_original_dst is set on the // listener in determining a filter chain match. + option (armeria.xds.supported.field) = 8; google.protobuf.UInt32Value destination_port = 8 [(validate.rules).uint32 = {lte: 65535 gte: 1}]; // If non-empty, an IP address and prefix length to match addresses when the @@ -163,6 +164,7 @@ message FilterChainMatch { // // See the :ref:`FAQ entry ` on how to configure SNI for more // information. + option (armeria.xds.supported.field) = 11; repeated string server_names = 11; // If non-empty, a transport protocol to consider when determining a filter chain match. @@ -174,6 +176,7 @@ message FilterChainMatch { // * ``raw_buffer`` - default, used when no transport protocol is detected, // * ``tls`` - set by :ref:`envoy.filters.listener.tls_inspector ` // when TLS protocol is detected. + option (armeria.xds.supported.field) = 9; string transport_protocol = 9; // If non-empty, a list of application protocols (e.g. ALPN for TLS protocol) to consider when @@ -195,6 +198,7 @@ message FilterChainMatch { // However, the use of ALPN is pretty much limited to the HTTP/2 traffic on the Internet, // and matching on values other than ``h2`` is going to lead to a lot of false negatives, // unless all connecting clients are known to use ALPN. + option (armeria.xds.supported.field) = 10; repeated string application_protocols = 10; } diff --git a/xds/src/main/java/com/linecorp/armeria/xds/FilterChainMatcher.java b/xds/src/main/java/com/linecorp/armeria/xds/FilterChainMatcher.java new file mode 100644 index 00000000000..cdbec3110a5 --- /dev/null +++ b/xds/src/main/java/com/linecorp/armeria/xds/FilterChainMatcher.java @@ -0,0 +1,250 @@ +/* + * 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.xds; + +import java.util.List; + +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.server.ConnectionContext; + +/** + * Selects the best-matching {@link FilterChainSnapshot} for a {@link ConnectionContext} + * using a BFS-style multi-pass narrowing algorithm that replicates Envoy's + * + * filter chain matching semantics. + * + *

At each priority level the candidate list is narrowed: if any candidate specifies + * a value for that criterion and the value matches the connection, only those candidates + * survive; otherwise the wildcard (unset) candidates are kept. If specific candidates + * exist but none match, the wildcards act as fallback. When all candidates are eliminated + * the default filter chain is returned. + * + *

Priority order (highest to lowest): + *

    + *
  1. {@code destination_port}
  2. + *
  3. {@code prefix_ranges} (destination IP)
  4. + *
  5. {@code server_names}
  6. + *
  7. {@code transport_protocol}
  8. + *
  9. {@code application_protocols}
  10. + *
  11. {@code direct_source_prefix_ranges}
  12. + *
  13. {@code source_type}
  14. + *
  15. {@code source_prefix_ranges}
  16. + *
  17. {@code source_ports}
  18. + *
+ * + *

Each {@link NarrowStep} precomputes the wildcard/specific partition of the full chain + * list. Levels where all chains are wildcard are skipped entirely at match time. When the + * candidate list has not been narrowed by a prior step (reference equality with the full + * list), the precomputed partition is reused to avoid redundant iteration. + */ +final class FilterChainMatcher { + + private abstract static class NarrowStep { + + private final List allChains; + private final ImmutableList precomputedSpecifics; + private final ImmutableList precomputedWildcards; + + NarrowStep(List allChains) { + this.allChains = allChains; + final ImmutableList.Builder specifics = ImmutableList.builder(); + final ImmutableList.Builder wildcards = ImmutableList.builder(); + for (FilterChainSnapshot fcs : allChains) { + if (isSpecific(fcs)) { + specifics.add(fcs); + } else { + wildcards.add(fcs); + } + } + precomputedSpecifics = specifics.build(); + precomputedWildcards = wildcards.build(); + } + + abstract boolean isSpecific(FilterChainSnapshot fcs); + + abstract List matchSpecifics(List specifics, + ConnectionContext ctx); + + final List narrow(List candidates, + ConnectionContext ctx) { + if (precomputedSpecifics.isEmpty()) { + return candidates; + } + if (candidates != allChains) { + return slowPath(candidates, ctx); + } + final List matched = matchSpecifics(precomputedSpecifics, ctx); + return matched.isEmpty() ? precomputedWildcards : matched; + } + + private List slowPath(List candidates, + ConnectionContext ctx) { + final ImmutableList.Builder specifics = ImmutableList.builder(); + final ImmutableList.Builder wildcards = ImmutableList.builder(); + for (FilterChainSnapshot fcs : candidates) { + if (isSpecific(fcs)) { + specifics.add(fcs); + } else { + wildcards.add(fcs); + } + } + final List matched = matchSpecifics(specifics.build(), ctx); + return matched.isEmpty() ? wildcards.build() : matched; + } + } + + private final ImmutableList allChains; + private final ImmutableList steps; + + FilterChainMatcher(List filterChains) { + allChains = ImmutableList.copyOf(filterChains); + // Steps are in priority order. Skipped upstream levels (2, 6-9) are omitted. + // 2. prefix_ranges (destination IP) — skipped. Envoy narrows by longest CIDR prefix match. + // 6. direct_source_prefix_ranges — skipped. Envoy narrows by CIDR match on direct remote address. + // 7. source_type — skipped. Envoy narrows by ANY/SAME_IP_OR_LOOPBACK/EXTERNAL. + // 8. source_prefix_ranges — skipped. Envoy narrows by CIDR match on remote address. + // 9. source_ports — skipped. Envoy narrows by exact match on remote port. + steps = ImmutableList.of( + new DestinationPortStep(allChains), // 1 + new ServerNamesStep(allChains), // 3 + new TransportProtocolStep(allChains), // 4 + new ApplicationProtocolsStep(allChains) // 5 + ); + } + + @Nullable + FilterChainSnapshot match(@Nullable FilterChainSnapshot defaultFilterChain, + ConnectionContext ctx) { + List candidates = allChains; + for (NarrowStep step : steps) { + candidates = step.narrow(candidates, ctx); + + if (candidates.isEmpty()) { + return defaultFilterChain; + } + } + return candidates.get(0); + } + + private static final class DestinationPortStep extends NarrowStep { + + DestinationPortStep(List allChains) { + super(allChains); + } + + @Override + boolean isSpecific(FilterChainSnapshot fcs) { + return fcs.filterChainMatch().hasDestinationPort(); + } + + @Override + List matchSpecifics(List specifics, + ConnectionContext ctx) { + final int port = ctx.localAddress().getPort(); + final ImmutableList.Builder matched = ImmutableList.builder(); + for (FilterChainSnapshot fcs : specifics) { + if (fcs.filterChainMatch().getDestinationPort().getValue() == port) { + matched.add(fcs); + } + } + return matched.build(); + } + } + + // Exact-match only; wildcard domain suffix matching (e.g. *.example.com) is not implemented. + private static final class ServerNamesStep extends NarrowStep { + + ServerNamesStep(List allChains) { + super(allChains); + } + + @Override + boolean isSpecific(FilterChainSnapshot fcs) { + return !fcs.filterChainMatch().getServerNamesList().isEmpty(); + } + + @Override + List matchSpecifics(List specifics, + ConnectionContext ctx) { + final String sniHostname = ctx.sniHostname(); + final ImmutableList.Builder matched = ImmutableList.builder(); + for (FilterChainSnapshot fcs : specifics) { + if (sniHostname != null && !sniHostname.isEmpty() && + fcs.filterChainMatch().getServerNamesList().contains(sniHostname)) { + matched.add(fcs); + } + } + return matched.build(); + } + } + + private static final class TransportProtocolStep extends NarrowStep { + + TransportProtocolStep(List allChains) { + super(allChains); + } + + @Override + boolean isSpecific(FilterChainSnapshot fcs) { + return !fcs.filterChainMatch().getTransportProtocol().isEmpty(); + } + + @Override + List matchSpecifics(List specifics, + ConnectionContext ctx) { + final String transportProtocol = ctx.sessionProtocol().isTls() ? "tls" : "raw_buffer"; + final ImmutableList.Builder matched = ImmutableList.builder(); + for (FilterChainSnapshot fcs : specifics) { + if (fcs.filterChainMatch().getTransportProtocol().equals(transportProtocol)) { + matched.add(fcs); + } + } + return matched.build(); + } + } + + private static final class ApplicationProtocolsStep extends NarrowStep { + + ApplicationProtocolsStep(List allChains) { + super(allChains); + } + + @Override + boolean isSpecific(FilterChainSnapshot fcs) { + return !fcs.filterChainMatch().getApplicationProtocolsList().isEmpty(); + } + + @Override + List matchSpecifics(List specifics, + ConnectionContext ctx) { + final List alpnProtocols = ctx.alpnProtocols(); + final ImmutableList.Builder matched = ImmutableList.builder(); + for (FilterChainSnapshot fcs : specifics) { + final List matchAlpn = fcs.filterChainMatch().getApplicationProtocolsList(); + for (String offered : alpnProtocols) { + if (matchAlpn.contains(offered)) { + matched.add(fcs); + break; + } + } + } + return matched.build(); + } + } +} 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 773e757d5c8..0c99f17d994 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/ListenerSnapshot.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/ListenerSnapshot.java @@ -44,6 +44,7 @@ public final class ListenerSnapshot implements Snapshot { private final RouteSnapshot apiListenerRoute; @Nullable private final RouteSnapshot defaultRouteSnapshot; + private final FilterChainMatcher filterChainMatcher; ListenerSnapshot(ListenerXdsResource listenerXdsResource, Optional apiListenerRoute, @@ -54,6 +55,7 @@ public final class ListenerSnapshot implements Snapshot { this.defaultFilterChain = defaultFilterChain.orElse(null); this.apiListenerRoute = apiListenerRoute.orElse(null); defaultRouteSnapshot = defaultRouteSnapshot(); + filterChainMatcher = new FilterChainMatcher(filterChains); } @Override @@ -111,11 +113,7 @@ public FilterChainSnapshot defaultFilterChain() { @Nullable public FilterChainSnapshot matchFilterChain(ConnectionContext ctx) { requireNonNull(ctx, "ctx"); - // Simple: use first filter chain or default. Full matching deferred to follow-up PR. - if (!filterChains.isEmpty()) { - return filterChains.get(0); - } - return defaultFilterChain; + return filterChainMatcher.match(defaultFilterChain, ctx); } @Override diff --git a/xds/src/main/java/com/linecorp/armeria/xds/ServerTlsSpecSelector.java b/xds/src/main/java/com/linecorp/armeria/xds/ServerTlsSpecSelector.java new file mode 100644 index 00000000000..0ae51065bc8 --- /dev/null +++ b/xds/src/main/java/com/linecorp/armeria/xds/ServerTlsSpecSelector.java @@ -0,0 +1,146 @@ +/* + * 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.xds; + +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.util.Exceptions; +import com.linecorp.armeria.server.ConnectionContext; +import com.linecorp.armeria.server.ServerTlsSpec; + +/** + * Selects the best-matching {@link ServerTlsSpec} for a connection using SNI-based + * certificate selection, following Envoy's + * + * certificate selection algorithm. + * + *

Selection order: + *

    + *
  1. Exact SNI match against certificate DNS SANs (or CN if no SANs)
  2. + *
  3. Wildcard match (one level only, e.g. {@code *.example.com})
  4. + *
  5. Fallback to the first certificate
  6. + *
+ * + *

The server name map follows Envoy's {@code DefaultTlsCertificateSelector}: exact names + * are stored as-is, wildcard names (e.g. {@code *.example.com}) are stored as + * {@code .example.com}. The first certificate for a given name wins. + */ +final class ServerTlsSpecSelector { + + private final ImmutableList specs; + private final Map serverNameMap; + + ServerTlsSpecSelector(List specs, + List tlsCertificates) { + this.specs = ImmutableList.copyOf(specs); + serverNameMap = buildServerNameMap(specs, tlsCertificates); + } + + @Nullable + ServerTlsSpec select(ConnectionContext ctx) { + if (specs.isEmpty()) { + return null; + } + final String sni = ctx.sniHostname(); + if (sni != null && !sni.isEmpty()) { + final String normalizedSni = sni.toLowerCase(Locale.ROOT); + // Exact match + final ServerTlsSpec exact = serverNameMap.get(normalizedSni); + if (exact != null) { + return exact; + } + // Wildcard match: "www.example.com" → ".example.com" + final int dotPos = normalizedSni.indexOf('.', 1); + if (dotPos > 0 && dotPos < normalizedSni.length() - 1) { + final ServerTlsSpec wildcard = serverNameMap.get(normalizedSni.substring(dotPos)); + if (wildcard != null) { + return wildcard; + } + } + } + return specs.get(0); + } + + private static Map buildServerNameMap( + List specs, List tlsCertificates) { + final Map nameMap = new HashMap<>(); + for (int i = 0; i < tlsCertificates.size(); i++) { + final TlsKeyPair keyPair = tlsCertificates.get(i).tlsKeyPair(); + if (keyPair == null || keyPair.certificateChain().isEmpty()) { + continue; + } + final ServerTlsSpec spec = specs.get(i); + final X509Certificate firstCert = keyPair.certificateChain().get(0); + for (String name : extractDnsNames(firstCert)) { + final String key = name.startsWith("*.") ? name.substring(1) : name; + nameMap.putIfAbsent(key, spec); + } + } + return ImmutableMap.copyOf(nameMap); + } + + // Uses DNS SANs if present; falls back to CN per RFC 6125 §6.4.4. + static List extractDnsNames(X509Certificate cert) { + final Collection> sans; + try { + sans = cert.getSubjectAlternativeNames(); + } catch (CertificateParsingException e) { + return Exceptions.throwUnsafely(e); + } + if (sans != null) { + final ImmutableList.Builder dnsNames = ImmutableList.builder(); + for (List san : sans) { + if (san.size() >= 2 && Objects.equals(2, san.get(0))) { + dnsNames.add(((String) san.get(1)).toLowerCase(Locale.ROOT)); + } + } + final ImmutableList result = dnsNames.build(); + if (!result.isEmpty()) { + return result; + } + } + final String cn = extractCn(cert); + if (cn != null) { + return ImmutableList.of(cn.toLowerCase(Locale.ROOT)); + } + return ImmutableList.of(); + } + + @Nullable + private static String extractCn(X509Certificate cert) { + final String dn = cert.getSubjectX500Principal().getName(); + for (String rdn : dn.split(",")) { + final String trimmed = rdn.trim(); + if (trimmed.toUpperCase(Locale.ROOT).startsWith("CN=")) { + return trimmed.substring(3); + } + } + return null; + } +} 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 cfd1e98ba37..15eb49532ed 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/TransportSocketSnapshot.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/TransportSocketSnapshot.java @@ -16,6 +16,8 @@ package com.linecorp.armeria.xds; +import static java.util.Objects.requireNonNull; + import java.security.cert.X509Certificate; import java.util.List; import java.util.Optional; @@ -60,14 +62,14 @@ public final class TransportSocketSnapshot implements Snapshot @Nullable private final ClientTlsSpec clientTlsSpec; @Nullable - private final ServerTlsSpec serverTlsSpec; + private final ServerTlsSpecSelector serverTlsSpecSelector; TransportSocketSnapshot(TransportSocket transportSocket) { this.transportSocket = transportSocket; tlsCertificates = ImmutableList.of(); validationContext = null; clientTlsSpec = null; - serverTlsSpec = null; + serverTlsSpecSelector = null; } TransportSocketSnapshot(TransportSocket transportSocket, @@ -80,7 +82,7 @@ public final class TransportSocketSnapshot implements Snapshot final TlsCertificateSnapshot firstCert = tlsCertificates.isEmpty() ? null : tlsCertificates.get(0); clientTlsSpec = buildClientTlsSpec(upstreamTlsContext, firstCert, this.validationContext); - serverTlsSpec = null; + serverTlsSpecSelector = null; } TransportSocketSnapshot(TransportSocket transportSocket, @@ -97,8 +99,7 @@ public final class TransportSocketSnapshot implements Snapshot .map(cert -> buildServerTlsSpec(downstreamTlsContext, cert, this.validationContext)) .collect(ImmutableList.toImmutableList()); - // Simple: use first spec. SNI-based selection deferred to follow-up PR. - serverTlsSpec = specs.isEmpty() ? null : specs.get(0); + serverTlsSpecSelector = new ServerTlsSpecSelector(specs, this.tlsCertificates); } @Override @@ -155,7 +156,8 @@ public List tlsCertificates() { * does not configure downstream TLS */ public @Nullable ServerTlsSpec serverTlsSpec(ConnectionContext ctx) { - return serverTlsSpec; + requireNonNull(ctx, "ctx"); + return serverTlsSpecSelector != null ? serverTlsSpecSelector.select(ctx) : null; } private static ClientTlsSpec buildClientTlsSpec( From 37fd9b6a82b4a02764d66bcdf7fd43fe37737ce0 Mon Sep 17 00:00:00 2001 From: jrhee17 Date: Thu, 2 Jul 2026 16:17:15 +0900 Subject: [PATCH 2/6] ai comments --- .../xds/it/server/ServerFilterChainMatchTest.java | 5 +++-- .../com/linecorp/armeria/xds/FilterChainMatcher.java | 11 +++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerFilterChainMatchTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerFilterChainMatchTest.java index 686c0639913..3d2f4942ce5 100644 --- a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerFilterChainMatchTest.java +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerFilterChainMatchTest.java @@ -20,7 +20,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.nio.file.Path; -import java.security.SignatureException; + +import javax.net.ssl.SSLHandshakeException; import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; @@ -264,7 +265,7 @@ void defaultFilterChainFallback() { HttpRequest.of(HttpMethod.GET, "/hello"), RequestOptions.builder().clientTlsSpec(wrongTlsSpec).build())) .isInstanceOf(UnprocessedRequestException.class) - .hasRootCauseInstanceOf(SignatureException.class); + .hasCauseInstanceOf(SSLHandshakeException.class); } @Test diff --git a/xds/src/main/java/com/linecorp/armeria/xds/FilterChainMatcher.java b/xds/src/main/java/com/linecorp/armeria/xds/FilterChainMatcher.java index cdbec3110a5..82ce9b5354c 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/FilterChainMatcher.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/FilterChainMatcher.java @@ -17,6 +17,7 @@ package com.linecorp.armeria.xds; import java.util.List; +import java.util.Locale; import com.google.common.collect.ImmutableList; @@ -180,13 +181,15 @@ boolean isSpecific(FilterChainSnapshot fcs) { } @Override - List matchSpecifics(List specifics, - ConnectionContext ctx) { + List matchSpecifics(List specifics, ConnectionContext ctx) { final String sniHostname = ctx.sniHostname(); + if (sniHostname == null || sniHostname.isEmpty()) { + return ImmutableList.of(); + } + final String normalizedSni = sniHostname.toLowerCase(Locale.ROOT); final ImmutableList.Builder matched = ImmutableList.builder(); for (FilterChainSnapshot fcs : specifics) { - if (sniHostname != null && !sniHostname.isEmpty() && - fcs.filterChainMatch().getServerNamesList().contains(sniHostname)) { + if (fcs.filterChainMatch().getServerNamesList().contains(normalizedSni)) { matched.add(fcs); } } From b76ab4a791a5b668f691d1e0571e6938229a6ee7 Mon Sep 17 00:00:00 2001 From: jrhee17 Date: Thu, 2 Jul 2026 16:46:23 +0900 Subject: [PATCH 3/6] ai comments --- .../java/com/linecorp/armeria/xds/ServerTlsSpecSelector.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/xds/src/main/java/com/linecorp/armeria/xds/ServerTlsSpecSelector.java b/xds/src/main/java/com/linecorp/armeria/xds/ServerTlsSpecSelector.java index 0ae51065bc8..fb08c859712 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/ServerTlsSpecSelector.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/ServerTlsSpecSelector.java @@ -120,10 +120,7 @@ static List extractDnsNames(X509Certificate cert) { dnsNames.add(((String) san.get(1)).toLowerCase(Locale.ROOT)); } } - final ImmutableList result = dnsNames.build(); - if (!result.isEmpty()) { - return result; - } + return dnsNames.build(); } final String cn = extractCn(cert); if (cn != null) { From 1deec8e6ede7f862a185a3a156bd9fb534164c4b Mon Sep 17 00:00:00 2001 From: jrhee17 Date: Thu, 2 Jul 2026 18:25:53 +0900 Subject: [PATCH 4/6] fix windows ci failure --- .../armeria/xds/it/BootstrapSecretsTest.java | 6 ++--- .../it/CertificateValidationContextTest.java | 18 +++++++------- .../it/ControlPlaneTlsIntegrationTest.java | 14 +++++------ .../armeria/xds/it/DataSourcePolicyTest.java | 4 ++-- .../armeria/xds/it/DataSourceTest.java | 24 +++++++++---------- .../armeria/xds/it/DynamicSecretTest.java | 16 ++++++------- .../armeria/xds/it/ErrorHandlingTest.java | 20 ++++++++-------- .../armeria/xds/it/PipeEndpointTest.java | 4 ++-- .../xds/it/ResourceNodeMetricTest.java | 4 ++-- .../TlsPeerVerificationIntegrationTest.java | 24 +++++++++---------- .../armeria/xds/it/XdsEndpointGroupTest.java | 4 ++-- .../armeria/xds/it/XdsPreprocessorTest.java | 4 ++-- .../xds/it/server/ServerDecoratorTest.java | 16 ++++++------- .../xds/it/server/ServerFallbackTest.java | 4 ++-- .../it/server/ServerFilterChainMatchTest.java | 16 ++++++------- .../xds/it/server/ServerMultiPortTest.java | 4 ++-- .../it/server/ServerMultiplePluginTest.java | 4 ++-- .../it/server/ServerTlsSpecSelectorTest.java | 8 +++---- .../armeria/xds/it/server/ServerXdsTest.java | 12 +++++----- 19 files changed, 103 insertions(+), 103 deletions(-) diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/BootstrapSecretsTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/BootstrapSecretsTest.java index 074db85c8b8..1591b94971c 100644 --- a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/BootstrapSecretsTest.java +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/BootstrapSecretsTest.java @@ -110,13 +110,13 @@ class BootstrapSecretsTest { - name: my-cert tls_certificate: private_key: - filename: %s + filename: '%s' certificate_chain: - filename: %s + filename: '%s' - name: my-validation validation_context: trusted_ca: - filename: %s + filename: '%s' """; @Test diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/CertificateValidationContextTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/CertificateValidationContextTest.java index c369883583c..37015d18382 100644 --- a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/CertificateValidationContextTest.java +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/CertificateValidationContextTest.java @@ -116,9 +116,9 @@ protected void configure(ServerBuilder sb) { common_tls_context: tls_certificates: - private_key: - filename: %s + filename: '%s' certificate_chain: - filename: %s + filename: '%s' combined_validation_context: default_validation_context: {} validation_context_sds_secret_config: @@ -159,7 +159,7 @@ void invalidCaCertificateFile(@TempDir File tempDir) throws Exception { name: validation-certs validation_context: trusted_ca: - filename: %s + filename: '%s' """.formatted(invalidCaFile.getAbsolutePath()); final Secret secret = XdsResourceReader.fromYaml(secretYaml, Secret.class); version.incrementAndGet(); @@ -275,7 +275,7 @@ void multipleCaCertificates(@TempDir File tempDir) throws Exception { name: validation-certs validation_context: trusted_ca: - filename: %s + filename: '%s' """.formatted(multiCaFile.getAbsolutePath()); final Secret secret = XdsResourceReader.fromYaml(secretYaml, Secret.class); version.incrementAndGet(); @@ -323,7 +323,7 @@ void validationContextWithSds(@TempDir File tempDir) throws Exception { name: validation-certs validation_context: trusted_ca: - filename: %s + filename: '%s' """.formatted(caFile.getAbsolutePath()); final Secret secret = XdsResourceReader.fromYaml(secretYaml, Secret.class); version.incrementAndGet(); @@ -398,9 +398,9 @@ void validationContextWithSds(@TempDir File tempDir) throws Exception { common_tls_context: tls_certificates: - private_key: - filename: %s + filename: '%s' certificate_chain: - filename: %s + filename: '%s' combined_validation_context: default_validation_context: match_subject_alt_names: @@ -443,7 +443,7 @@ void mergeValidationContextWithBase(@TempDir File tempDir) throws Exception { name: validation-certs validation_context: trusted_ca: - filename: %s + filename: '%s' """.formatted(caFile.getAbsolutePath()); final Secret secret = XdsResourceReader.fromYaml(secretYaml, Secret.class); version.incrementAndGet(); @@ -491,7 +491,7 @@ void overrideBaseContextFields(@TempDir File tempDir) throws Exception { name: validation-certs validation_context: trusted_ca: - filename: %s + filename: '%s' match_subject_alt_names: - exact: "override.example.com" """.formatted(caFile.getAbsolutePath()); diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ControlPlaneTlsIntegrationTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ControlPlaneTlsIntegrationTest.java index 8d6edefd9e6..97d9decf641 100644 --- a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ControlPlaneTlsIntegrationTest.java +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ControlPlaneTlsIntegrationTest.java @@ -157,7 +157,7 @@ void controlPlaneHttpsWithTrustedCaAndSanMatch() { """ validation_context: trusted_ca: - filename: %s + filename: '%s' match_typed_subject_alt_names: - san_type: IP_ADDRESS matcher: @@ -192,7 +192,7 @@ void controlPlaneHttpsFailsWithSanMismatch() { """ validation_context: trusted_ca: - filename: %s + filename: '%s' match_typed_subject_alt_names: - san_type: IP_ADDRESS matcher: @@ -219,7 +219,7 @@ void controlPlaneHttpsFailsWithBadSpkiPin() throws Exception { """ validation_context: trusted_ca: - filename: %s + filename: '%s' verify_certificate_spki: - "%s" match_typed_subject_alt_names: @@ -247,7 +247,7 @@ void controlPlaneHttpsFailsWhenClientCertRequired() { """ validation_context: trusted_ca: - filename: %s + filename: '%s' match_typed_subject_alt_names: - san_type: IP_ADDRESS matcher: @@ -273,12 +273,12 @@ void controlPlaneHttpsSucceedsWithClientCert() { """ tls_certificates: - private_key: - filename: %s + filename: '%s' certificate_chain: - filename: %s + filename: '%s' validation_context: trusted_ca: - filename: %s + filename: '%s' match_typed_subject_alt_names: - san_type: IP_ADDRESS matcher: diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/DataSourcePolicyTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/DataSourcePolicyTest.java index 317fcd19166..aa4af8e2fb4 100644 --- a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/DataSourcePolicyTest.java +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/DataSourcePolicyTest.java @@ -179,9 +179,9 @@ private static Bootstrap bootstrapYaml(Path certsDir) { common_tls_context: tls_certificates: - private_key: - filename: %s + filename: '%s' certificate_chain: - filename: %s + filename: '%s' """.formatted(certsDir.resolve("private_key.pem"), certsDir.resolve("certificate.pem")); return XdsResourceReader.fromYaml(bootstrapStr, Bootstrap.class); diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/DataSourceTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/DataSourceTest.java index 741a5ae23b2..250e36bdbc0 100644 --- a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/DataSourceTest.java +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/DataSourceTest.java @@ -87,9 +87,9 @@ void tlsCertificateWithPrivateKeyAndCertificateChain() throws Exception { name: my-cert tls_certificate: private_key: - filename: %s + filename: '%s' certificate_chain: - filename: %s + filename: '%s' """.formatted(certificate1.privateKeyFile().toPath().toString(), certificate1.certificateFile().toPath().toString()); final Secret secret = XdsResourceReader.fromYaml(tlsCertYaml, Secret.class); @@ -205,9 +205,9 @@ void certificateFilesChanged(@TempDir File tempDir) throws Exception { name: my-cert tls_certificate: private_key: - filename: %s + filename: '%s' certificate_chain: - filename: %s + filename: '%s' """.formatted(privateKeyFile.getAbsolutePath(), certificateFile.getAbsolutePath()); final Secret secret = XdsResourceReader.fromYaml(tlsCertYaml, Secret.class); @@ -344,9 +344,9 @@ void watchedDirectory(@TempDir File filesDir, @TempDir File watchDir) throws Exc watched_directory: path: %s private_key: - filename: %s + filename: '%s' certificate_chain: - filename: %s + filename: '%s' """.formatted(watchDir.getAbsolutePath(), privateKeyFile.getAbsolutePath(), certificateFile.getAbsolutePath()); @@ -496,9 +496,9 @@ void missingFilesWithoutWatchedDirectory(@TempDir File tempDir) throws Exception name: my-cert tls_certificate: private_key: - filename: %s + filename: '%s' certificate_chain: - filename: %s + filename: '%s' """.formatted(privateKeyFile.getAbsolutePath(), certificateFile.getAbsolutePath()); final Secret secret = XdsResourceReader.fromYaml(tlsCertYaml, Secret.class); @@ -615,9 +615,9 @@ void missingFilesWithWatchedDirectory(@TempDir File filesDir, @TempDir File watc watched_directory: path: %s private_key: - filename: %s + filename: '%s' certificate_chain: - filename: %s + filename: '%s' """.formatted(watchDir.getAbsolutePath(), privateKeyFile.getAbsolutePath(), certificateFile.getAbsolutePath()); @@ -1054,9 +1054,9 @@ void symlinkRotation(@TempDir File tempDir) throws Exception { watched_directory: path: %s private_key: - filename: %s + filename: '%s' certificate_chain: - filename: %s + filename: '%s' """.formatted(tempDir.getAbsolutePath(), privateKeyFile.getAbsolutePath(), certificateFile.getAbsolutePath()); diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/DynamicSecretTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/DynamicSecretTest.java index ece3060e938..042e66aad7d 100644 --- a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/DynamicSecretTest.java +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/DynamicSecretTest.java @@ -84,9 +84,9 @@ protected void configure(ServerBuilder sb) { name: my-cert tls_certificate: private_key: - filename: %s + filename: '%s' certificate_chain: - filename: %s + filename: '%s' """; //language=YAML private static final String validationContextYaml = @@ -94,7 +94,7 @@ protected void configure(ServerBuilder sb) { name: my-validation validation_context: trusted_ca: - filename: %s + filename: '%s' """; // YAML @@ -168,7 +168,7 @@ protected void configure(ServerBuilder sb) { - name: my-validation validation_context: trusted_ca: - filename: %s + filename: '%s' """; @Test @@ -285,7 +285,7 @@ void sdsSecretLoadedWithAds() throws Exception { - name: my-validation validation_context: trusted_ca: - filename: %s + filename: '%s' """; @Test @@ -504,7 +504,7 @@ void sdsValidationContextOnly() throws Exception { name: my-cert-validation validation_context: trusted_ca: - filename: %s + filename: '%s' """.formatted(certificate2.certificateFile().toPath().toString()); final Secret secret2 = XdsResourceReader.fromYaml(validationYaml, Secret.class); version.incrementAndGet(); @@ -604,9 +604,9 @@ void sdsValidationContextOnly() throws Exception { - name: my-cert tls_certificate: private_key: - filename: %s + filename: '%s' certificate_chain: - filename: %s + filename: '%s' """; @Test diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ErrorHandlingTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ErrorHandlingTest.java index 9d0a00a166d..f2c654d352b 100644 --- a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ErrorHandlingTest.java +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ErrorHandlingTest.java @@ -701,9 +701,9 @@ void sdsInvalidCertificateFile(@TempDir File tempDir) throws Exception { name: my-cert tls_certificate: private_key: - filename: %s + filename: '%s' certificate_chain: - filename: %s + filename: '%s' """.formatted(certificate.privateKeyFile().toPath().toString(), invalidCertFile.getAbsolutePath()); final Secret secret = XdsResourceReader.fromYaml(secretYaml, Secret.class); @@ -748,9 +748,9 @@ void sdsInvalidPrivateKeyFile(@TempDir File tempDir) throws Exception { name: my-cert tls_certificate: private_key: - filename: %s + filename: '%s' certificate_chain: - filename: %s + filename: '%s' """.formatted(invalidKeyFile.getAbsolutePath(), certificate.certificateFile().toPath().toString()); final Secret secret = XdsResourceReader.fromYaml(secretYaml, Secret.class); @@ -794,9 +794,9 @@ void sdsMissingCertificateFile(@TempDir File tempDir) throws Exception { name: my-cert tls_certificate: private_key: - filename: %s + filename: '%s' certificate_chain: - filename: %s + filename: '%s' """.formatted(certificate.privateKeyFile().toPath().toString(), missingFile.getAbsolutePath()); final Secret secret = XdsResourceReader.fromYaml(secretYaml, Secret.class); @@ -836,9 +836,9 @@ void sdsMissingSecretName() throws Exception { name: wrong-cert-name tls_certificate: private_key: - filename: %s + filename: '%s' certificate_chain: - filename: %s + filename: '%s' """.formatted(certificate.privateKeyFile().toPath().toString(), certificate.certificateFile().toPath().toString()); final Secret secret = XdsResourceReader.fromYaml(secretYaml, Secret.class); @@ -872,9 +872,9 @@ void sdsMissingSecretName() throws Exception { name: my-cert tls_certificate: private_key: - filename: %s + filename: '%s' certificate_chain: - filename: %s + filename: '%s' """.formatted(certificate.privateKeyFile().toPath().toString(), certificate.certificateFile().toPath().toString()); final Secret correctSecret = XdsResourceReader.fromYaml(correctSecretYaml, Secret.class); diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/PipeEndpointTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/PipeEndpointTest.java index 08a19286f98..a6f4525b8bb 100644 --- a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/PipeEndpointTest.java +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/PipeEndpointTest.java @@ -331,9 +331,9 @@ private static Secret tlsCertSecret(String name, XdsCertificateExtension cert) { name: %s tls_certificate: private_key: - filename: %s + filename: '%s' certificate_chain: - filename: %s + filename: '%s' """.formatted(name, cert.privateKeyFile().toPath().toString(), cert.certificateFile().toPath().toString()); diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ResourceNodeMetricTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ResourceNodeMetricTest.java index 5b1fb7a10fb..1f9015aa705 100644 --- a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ResourceNodeMetricTest.java +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/ResourceNodeMetricTest.java @@ -1032,9 +1032,9 @@ void listenerRootWithSdsSecretUpdate() throws Exception { name: my-cert tls_certificate: private_key: - filename: %s + filename: '%s' certificate_chain: - filename: %s + filename: '%s' """; //language=YAML diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/TlsPeerVerificationIntegrationTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/TlsPeerVerificationIntegrationTest.java index 4547f0e7d11..e4e644bb0d4 100644 --- a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/TlsPeerVerificationIntegrationTest.java +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/TlsPeerVerificationIntegrationTest.java @@ -220,7 +220,7 @@ void requestSucceedsWithPinnedCertificateAndSanMatch() throws Exception { final String validationContext = """ trusted_ca: - filename: %s + filename: '%s' verify_certificate_spki: - "%s" verify_certificate_hash: @@ -250,7 +250,7 @@ void requestSucceedsWithMatchingSpkiOnly() throws Exception { final String validationContext = """ trusted_ca: - filename: %s + filename: '%s' verify_certificate_spki: - "%s" match_typed_subject_alt_names: @@ -278,7 +278,7 @@ void requestSucceedsWithMatchingCertHashOnly() throws Exception { final String validationContext = """ trusted_ca: - filename: %s + filename: '%s' verify_certificate_hash: - "%s" match_typed_subject_alt_names: @@ -307,7 +307,7 @@ void requestSucceedsWithCertHashWhenSpkiMismatched() throws Exception { final String validationContext = """ trusted_ca: - filename: %s + filename: '%s' verify_certificate_spki: - "%s" verify_certificate_hash: @@ -338,7 +338,7 @@ void requestSucceedsWithCaseInsensitiveSanMatch() throws Exception { final String validationContext = """ trusted_ca: - filename: %s + filename: '%s' verify_certificate_spki: - "%s" verify_certificate_hash: @@ -368,7 +368,7 @@ void requestSucceedsWithIpSanMatch() throws Exception { final String validationContext = """ trusted_ca: - filename: %s + filename: '%s' match_typed_subject_alt_names: - san_type: IP_ADDRESS matcher: @@ -393,7 +393,7 @@ void requestSucceedsWithWildcardDnsSanMatch() throws Exception { final String validationContext = """ trusted_ca: - filename: %s + filename: '%s' match_typed_subject_alt_names: - san_type: DNS matcher: @@ -418,7 +418,7 @@ void requestSucceedsWithUriSanPrefixMatch() throws Exception { final String validationContext = """ trusted_ca: - filename: %s + filename: '%s' match_typed_subject_alt_names: - san_type: URI matcher: @@ -443,7 +443,7 @@ void requestSucceedsWithNonExactSanMatchers() throws Exception { final String validationContext = """ trusted_ca: - filename: %s + filename: '%s' match_typed_subject_alt_names: - san_type: DNS matcher: @@ -482,7 +482,7 @@ void requestSucceedsWithColonSeparatedCertHash() throws Exception { final String validationContext = """ trusted_ca: - filename: %s + filename: '%s' verify_certificate_hash: - "%s" match_typed_subject_alt_names: @@ -511,7 +511,7 @@ void requestFailsWithMismatchedSpkiPin() throws Exception { final String validationContext = """ trusted_ca: - filename: %s + filename: '%s' verify_certificate_spki: - "%s" verify_certificate_hash: @@ -541,7 +541,7 @@ void requestFailsWithMismatchedSan() throws Exception { final String validationContext = """ trusted_ca: - filename: %s + filename: '%s' verify_certificate_spki: - "%s" verify_certificate_hash: diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/XdsEndpointGroupTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/XdsEndpointGroupTest.java index 83f716ad0af..4d96545967e 100644 --- a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/XdsEndpointGroupTest.java +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/XdsEndpointGroupTest.java @@ -208,7 +208,7 @@ private static Cluster clusterYaml(String name, @Nullable File trustedCaFile) { common_tls_context: validation_context: trusted_ca: - filename: %s + filename: '%s' """.formatted(name, trustedCaFile.getAbsolutePath()); } return XdsResourceReader.fromYaml(yaml, Cluster.class); @@ -332,7 +332,7 @@ private static Bootstrap bootstrapYaml(String clusterName, String address, int p common_tls_context: validation_context: trusted_ca: - filename: %s + filename: '%s' """.formatted(clusterName, clusterName, clusterName, address, port, trustedCaFile.getAbsolutePath()); } diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/XdsPreprocessorTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/XdsPreprocessorTest.java index feee23c038c..7d4b8b7d25f 100644 --- a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/XdsPreprocessorTest.java +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/XdsPreprocessorTest.java @@ -206,7 +206,7 @@ private static Cluster clusterYaml(String name, @Nullable File trustedCaFile) { common_tls_context: validation_context: trusted_ca: - filename: %s + filename: '%s' """.formatted(name, trustedCaFile.getAbsolutePath()); } return XdsResourceReader.fromYaml(yaml, Cluster.class); @@ -330,7 +330,7 @@ private static Bootstrap bootstrapYaml(String clusterName, String address, int p common_tls_context: validation_context: trusted_ca: - filename: %s + filename: '%s' """.formatted(clusterName, clusterName, clusterName, address, port, trustedCaFile.getAbsolutePath()); } diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerDecoratorTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerDecoratorTest.java index fb780cd684a..1215059eea1 100644 --- a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerDecoratorTest.java +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerDecoratorTest.java @@ -130,9 +130,9 @@ void xdsDecoratorAddsResponseHeader() { common_tls_context: tls_certificates: - certificate_chain: - filename: "%s" + filename: '%s' private_key: - filename: "%s" + filename: '%s' """.formatted(LISTENER_NAME, certPath, keyPath); final String ver = controlPlane.set(XdsResourceReader.fromYaml(yaml, Listener.class)); controlPlane.awaitListener(LISTENER_NAME, ver); @@ -186,9 +186,9 @@ void noMatchingRoute() { common_tls_context: tls_certificates: - certificate_chain: - filename: "%s" + filename: '%s' private_key: - filename: "%s" + filename: '%s' """.formatted(LISTENER_NAME, certPath, keyPath); final String ver = controlPlane.set(XdsResourceReader.fromYaml(yaml, Listener.class)); controlPlane.awaitListener(LISTENER_NAME, ver); @@ -239,9 +239,9 @@ void noRouterFilter() { common_tls_context: tls_certificates: - certificate_chain: - filename: "%s" + filename: '%s' private_key: - filename: "%s" + filename: '%s' """.formatted(LISTENER_NAME, certPath, keyPath); final String ver = controlPlane.set(XdsResourceReader.fromYaml(yaml, Listener.class)); controlPlane.awaitListener(LISTENER_NAME, ver); @@ -304,9 +304,9 @@ void perRouteFilterConfig() { common_tls_context: tls_certificates: - certificate_chain: - filename: "%s" + filename: '%s' private_key: - filename: "%s" + filename: '%s' """.formatted(LISTENER_NAME, certPath, keyPath); final String ver = controlPlane.set(XdsResourceReader.fromYaml(yaml, Listener.class)); controlPlane.awaitListener(LISTENER_NAME, ver); diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerFallbackTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerFallbackTest.java index 310864e6bd2..c890fdb66bb 100644 --- a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerFallbackTest.java +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerFallbackTest.java @@ -118,9 +118,9 @@ protected void configure(ServerBuilder sb) { common_tls_context: tls_certificates: - certificate_chain: - filename: "%s" + filename: '%s' private_key: - filename: "%s" + filename: '%s' """.formatted(LISTENER_NAME, certPath, keyPath); controlPlane.set(XdsResourceReader.fromYaml(yaml, Listener.class)); diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerFilterChainMatchTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerFilterChainMatchTest.java index 3d2f4942ce5..42a3eafc46a 100644 --- a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerFilterChainMatchTest.java +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerFilterChainMatchTest.java @@ -120,9 +120,9 @@ void matchByTransportProtocol() { common_tls_context: tls_certificates: - certificate_chain: - filename: "%s" + filename: '%s' private_key: - filename: "%s" + filename: '%s' - filter_chain_match: transport_protocol: "raw_buffer" filters: @@ -208,9 +208,9 @@ void defaultFilterChainFallback() { common_tls_context: tls_certificates: - certificate_chain: - filename: "%s" + filename: '%s' private_key: - filename: "%s" + filename: '%s' default_filter_chain: filters: - name: envoy.filters.network.http_connection_manager @@ -237,9 +237,9 @@ void defaultFilterChainFallback() { common_tls_context: tls_certificates: - certificate_chain: - filename: "%s" + filename: '%s' private_key: - filename: "%s" + filename: '%s' """.formatted(LISTENER_NAME, certPathA, keyPathA, certPathDefault, keyPathDefault); final String ver = controlPlane.set(XdsResourceReader.fromYaml(yaml, Listener.class)); controlPlane.awaitListener(LISTENER_NAME, ver); @@ -308,9 +308,9 @@ void unmatchedConnectionRejected() { common_tls_context: tls_certificates: - certificate_chain: - filename: "%s" + filename: '%s' private_key: - filename: "%s" + filename: '%s' """.formatted(LISTENER_NAME, certPathA, keyPathA); final String ver = controlPlane.set(XdsResourceReader.fromYaml(yaml, Listener.class)); controlPlane.awaitListener(LISTENER_NAME, ver); diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerMultiPortTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerMultiPortTest.java index 72bfd48e22c..0bed7ee10cc 100644 --- a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerMultiPortTest.java +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerMultiPortTest.java @@ -97,9 +97,9 @@ protected void configure(ServerBuilder sb) { common_tls_context: tls_certificates: - certificate_chain: - filename: "%s" + filename: '%s' private_key: - filename: "%s" + filename: '%s' """.formatted(LISTENER_NAME, certPath, keyPath); controlPlane.set(XdsResourceReader.fromYaml(yaml, Listener.class)); sb.plugin(XdsServerPlugin.of(controlPlane.bootstrap(), LISTENER_NAME, 0, 0)); diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerMultiplePluginTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerMultiplePluginTest.java index 72799415229..d47bbd83514 100644 --- a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerMultiplePluginTest.java +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerMultiplePluginTest.java @@ -120,9 +120,9 @@ private static Listener buildListener(String listenerName, XdsCertificateExtensi common_tls_context: tls_certificates: - certificate_chain: - filename: "%s" + filename: '%s' private_key: - filename: "%s" + filename: '%s' """.formatted(listenerName, certPath, keyPath); return XdsResourceReader.fromYaml(yaml, Listener.class); diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerTlsSpecSelectorTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerTlsSpecSelectorTest.java index 7f179c34470..a786d57244e 100644 --- a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerTlsSpecSelectorTest.java +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerTlsSpecSelectorTest.java @@ -103,13 +103,13 @@ protected void configure(ServerBuilder sb) { common_tls_context: tls_certificates: - certificate_chain: - filename: "%s" + filename: '%s' private_key: - filename: "%s" + filename: '%s' - certificate_chain: - filename: "%s" + filename: '%s' private_key: - filename: "%s" + filename: '%s' """.formatted(LISTENER_NAME, certPathFoo, keyPathFoo, certPathBar, keyPathBar); controlPlane.set(XdsResourceReader.fromYaml(yaml, Listener.class)); sb.plugin(XdsServerPlugin.of(controlPlane.bootstrap(), LISTENER_NAME)); diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerXdsTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerXdsTest.java index 71768554ec4..13aa6c35912 100644 --- a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerXdsTest.java +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerXdsTest.java @@ -108,12 +108,12 @@ protected void configure(ServerBuilder sb) { common_tls_context: tls_certificates: - certificate_chain: - filename: "%s" + filename: '%s' private_key: - filename: "%s" + filename: '%s' validation_context: trusted_ca: - filename: "%s" + filename: '%s' """.formatted(LISTENER_NAME, serverCertPath, serverKeyPath, clientCaCertPath); controlPlane.set(XdsResourceReader.fromYaml(yaml, Listener.class)); sb.plugin(XdsServerPlugin.builder(controlPlane.bootstrap(), LISTENER_NAME) @@ -177,12 +177,12 @@ void mutualTlsTest() { common_tls_context: tls_certificates: - certificate_chain: - filename: "%s" + filename: '%s' private_key: - filename: "%s" + filename: '%s' validation_context: trusted_ca: - filename: "%s" + filename: '%s' """.formatted(xdsPort.actualPort(), clientCertPath, clientKeyPath, serverCaCertPath); final Bootstrap clientBootstrap = From 33cf87653ced7b6277c53f867c9d9262a4b0ae50 Mon Sep 17 00:00:00 2001 From: jrhee17 Date: Mon, 6 Jul 2026 12:16:18 +0900 Subject: [PATCH 5/6] address comments by @ikhoon and @minwoox --- .../internal/common/util/CertificateUtil.java | 30 +++++ .../it/server/ServerFilterChainMatchTest.java | 112 ++++++++++++++++++ .../armeria/xds/FilterChainMatcher.java | 44 ++++++- .../armeria/xds/ServerTlsSpecSelector.java | 48 +------- 4 files changed, 188 insertions(+), 46 deletions(-) diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/util/CertificateUtil.java b/core/src/main/java/com/linecorp/armeria/internal/common/util/CertificateUtil.java index 7026c111410..1d7f92d62f8 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/common/util/CertificateUtil.java +++ b/core/src/main/java/com/linecorp/armeria/internal/common/util/CertificateUtil.java @@ -28,6 +28,7 @@ import java.security.cert.X509Certificate; import java.util.Collection; import java.util.List; +import java.util.Locale; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLSession; @@ -123,6 +124,35 @@ private static String extractSubjectAlternativeName(X509Certificate cert) { } } + public static List extractDnsNames(X509Certificate cert) { + requireNonNull(cert, "cert"); + try { + final Collection> altNames = cert.getSubjectAlternativeNames(); + if (altNames != null) { + final ImmutableList.Builder dnsNames = ImmutableList.builder(); + for (List altName : altNames) { + final Integer type = altName.size() >= 2 ? (Integer) altName.get(0) : null; + // Type 2 is DNS name. + if (type != null && type == 2) { + dnsNames.add(((String) altName.get(1)).toLowerCase(Locale.ROOT)); + } + } + return dnsNames.build(); + } + } catch (CertificateParsingException e) { + return Exceptions.throwUnsafely(e); + } + try { + final String cn = extractCommonName(cert); + if (cn != null) { + return ImmutableList.of(cn.toLowerCase(Locale.ROOT)); + } + } catch (CertificateEncodingException e) { + return Exceptions.throwUnsafely(e); + } + return ImmutableList.of(); + } + @Nullable public static String getHostname(SSLSession session) { final Certificate[] certs = session.getLocalCertificates(); diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerFilterChainMatchTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerFilterChainMatchTest.java index 42a3eafc46a..67e052f7cbc 100644 --- a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerFilterChainMatchTest.java +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerFilterChainMatchTest.java @@ -29,6 +29,7 @@ import com.linecorp.armeria.client.BlockingWebClient; import com.linecorp.armeria.client.ClientTlsSpec; +import com.linecorp.armeria.client.Endpoint; import com.linecorp.armeria.client.RequestOptions; import com.linecorp.armeria.client.UnprocessedRequestException; import com.linecorp.armeria.client.WebClient; @@ -37,6 +38,7 @@ import com.linecorp.armeria.common.HttpRequest; import com.linecorp.armeria.common.HttpResponse; import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.SessionProtocol; import com.linecorp.armeria.server.ServerBuilder; import com.linecorp.armeria.testing.junit5.server.SelfSignedCertificateExtension; import com.linecorp.armeria.testing.junit5.server.ServerExtension; @@ -268,6 +270,116 @@ void defaultFilterChainFallback() { .hasCauseInstanceOf(SSLHandshakeException.class); } + @Test + void wildcardServerNameMatch() { + final Path certPathA = certA.certificateFile().toPath(); + final Path keyPathA = certA.privateKeyFile().toPath(); + final Path certPathDefault = certDefault.certificateFile().toPath(); + final Path keyPathDefault = certDefault.privateKeyFile().toPath(); + + // Chain with wildcard server_names=["*.example.com"] and certA. + // Default chain with certDefault. + //language=YAML + final String yaml = + """ + name: %s + filter_chains: + - filter_chain_match: + server_names: + - "*.example.com" + transport_protocol: "tls" + 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: "/" + non_forwarding_action: {} + http_filters: + - name: envoy.filters.http.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' + 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: "/" + non_forwarding_action: {} + http_filters: + - name: envoy.filters.http.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' + """.formatted(LISTENER_NAME, certPathA, keyPathA, certPathDefault, keyPathDefault); + final String ver = controlPlane.set(XdsResourceReader.fromYaml(yaml, Listener.class)); + controlPlane.awaitListener(LISTENER_NAME, ver); + + final int port = server.httpsPort(); + + // SNI "test.example.com" should match "*.example.com" chain → certA + final ClientTlsSpec wildcardTlsSpec = ClientTlsSpec.builder() + .trustedCertificates(certA.certificate()) + .endpointIdentificationAlgorithm("") + .build(); + final Endpoint wildcardEndpoint = Endpoint.of("test.example.com", port).withIpAddr("127.0.0.1"); + final BlockingWebClient wildcardClient = + WebClient.builder(SessionProtocol.HTTPS, wildcardEndpoint).build().blocking(); + final AggregatedHttpResponse wildcardRes = wildcardClient.execute( + HttpRequest.of(HttpMethod.GET, "/hello"), + RequestOptions.builder().clientTlsSpec(wildcardTlsSpec).build()); + assertThat(wildcardRes.status()).isEqualTo(HttpStatus.OK); + assertThat(wildcardRes.contentUtf8()).isEqualTo("hello"); + + // SNI "other.net" should NOT match "*.example.com" → default chain → certDefault + final ClientTlsSpec defaultTlsSpec = ClientTlsSpec.builder() + .trustedCertificates(certDefault.certificate()) + .endpointIdentificationAlgorithm("") + .build(); + final Endpoint otherEndpoint = Endpoint.of("other.net", port).withIpAddr("127.0.0.1"); + final BlockingWebClient defaultClient = + WebClient.builder(SessionProtocol.HTTPS, otherEndpoint).build().blocking(); + final AggregatedHttpResponse defaultRes = defaultClient.execute( + HttpRequest.of(HttpMethod.GET, "/hello"), + RequestOptions.builder().clientTlsSpec(defaultTlsSpec).build()); + assertThat(defaultRes.status()).isEqualTo(HttpStatus.OK); + assertThat(defaultRes.contentUtf8()).isEqualTo("hello"); + } + @Test void unmatchedConnectionRejected() { final Path certPathA = certA.certificateFile().toPath(); diff --git a/xds/src/main/java/com/linecorp/armeria/xds/FilterChainMatcher.java b/xds/src/main/java/com/linecorp/armeria/xds/FilterChainMatcher.java index 82ce9b5354c..017ab89aa63 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/FilterChainMatcher.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/FilterChainMatcher.java @@ -168,7 +168,10 @@ List matchSpecifics(List specifics, } } - // Exact-match only; wildcard domain suffix matching (e.g. *.example.com) is not implemented. + // Supports exact and wildcard (e.g. *.example.com) server name matching following Envoy's + // findFilterChainForServerName semantics: exact match first, then progressively less specific + // wildcard suffixes (".example.com" before ".com"), then catch-all (no server_names). + // https://github.com/envoyproxy/envoy/blob/74ef415/source/common/listener_manager/filter_chain_manager_impl.cc#L608-L637 private static final class ServerNamesStep extends NarrowStep { ServerNamesStep(List allChains) { @@ -187,13 +190,46 @@ List matchSpecifics(List specifics, Co return ImmutableList.of(); } final String normalizedSni = sniHostname.toLowerCase(Locale.ROOT); - final ImmutableList.Builder matched = ImmutableList.builder(); + // exact match + final ImmutableList.Builder exactMatched = ImmutableList.builder(); for (FilterChainSnapshot fcs : specifics) { if (fcs.filterChainMatch().getServerNamesList().contains(normalizedSni)) { - matched.add(fcs); + exactMatched.add(fcs); } } - return matched.build(); + final ImmutableList exact = exactMatched.build(); + if (!exact.isEmpty()) { + return exact; + } + // wildcard match + int pos = normalizedSni.indexOf('.', 1); + while (pos > 0 && pos < normalizedSni.length() - 1) { + final String suffix = normalizedSni.substring(pos); + final ImmutableList.Builder wildcardMatched = ImmutableList.builder(); + for (FilterChainSnapshot fcs : specifics) { + if (hasWildcardServerName(fcs.filterChainMatch().getServerNamesList(), suffix)) { + wildcardMatched.add(fcs); + } + } + final ImmutableList wildcard = wildcardMatched.build(); + if (!wildcard.isEmpty()) { + return wildcard; + } + pos = normalizedSni.indexOf('.', pos + 1); + } + return ImmutableList.of(); + } + + // Checks if any server name in the list is a wildcard that matches the given suffix. + // e.g., "*.example.com" matches suffix ".example.com" but not ".com". + private static boolean hasWildcardServerName(List serverNames, String suffix) { + for (String name : serverNames) { + if (name.charAt(0) == '*' && name.length() == suffix.length() + 1 && + name.endsWith(suffix)) { + return true; + } + } + return false; } } diff --git a/xds/src/main/java/com/linecorp/armeria/xds/ServerTlsSpecSelector.java b/xds/src/main/java/com/linecorp/armeria/xds/ServerTlsSpecSelector.java index fb08c859712..79cb1dd88af 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/ServerTlsSpecSelector.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/ServerTlsSpecSelector.java @@ -16,21 +16,18 @@ package com.linecorp.armeria.xds; -import java.security.cert.CertificateParsingException; import java.security.cert.X509Certificate; -import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Objects; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.linecorp.armeria.common.TlsKeyPair; import com.linecorp.armeria.common.annotation.Nullable; -import com.linecorp.armeria.common.util.Exceptions; +import com.linecorp.armeria.internal.common.util.CertificateUtil; import com.linecorp.armeria.server.ConnectionContext; import com.linecorp.armeria.server.ServerTlsSpec; @@ -75,7 +72,10 @@ ServerTlsSpec select(ConnectionContext ctx) { if (exact != null) { return exact; } - // Wildcard match: "www.example.com" → ".example.com" + // Wildcard match: "www.example.com" → look up ".example.com" + // Follows Envoy's DefaultTlsCertificateSelector: strip the first DNS label and + // look up the suffix. + // https://github.com/envoyproxy/envoy/blob/74ef415/source/common/tls/default_tls_certificate_selector.cc#L254-L260 final int dotPos = normalizedSni.indexOf('.', 1); if (dotPos > 0 && dotPos < normalizedSni.length() - 1) { final ServerTlsSpec wildcard = serverNameMap.get(normalizedSni.substring(dotPos)); @@ -97,47 +97,11 @@ private static Map buildServerNameMap( } final ServerTlsSpec spec = specs.get(i); final X509Certificate firstCert = keyPair.certificateChain().get(0); - for (String name : extractDnsNames(firstCert)) { + for (String name : CertificateUtil.extractDnsNames(firstCert)) { final String key = name.startsWith("*.") ? name.substring(1) : name; nameMap.putIfAbsent(key, spec); } } return ImmutableMap.copyOf(nameMap); } - - // Uses DNS SANs if present; falls back to CN per RFC 6125 §6.4.4. - static List extractDnsNames(X509Certificate cert) { - final Collection> sans; - try { - sans = cert.getSubjectAlternativeNames(); - } catch (CertificateParsingException e) { - return Exceptions.throwUnsafely(e); - } - if (sans != null) { - final ImmutableList.Builder dnsNames = ImmutableList.builder(); - for (List san : sans) { - if (san.size() >= 2 && Objects.equals(2, san.get(0))) { - dnsNames.add(((String) san.get(1)).toLowerCase(Locale.ROOT)); - } - } - return dnsNames.build(); - } - final String cn = extractCn(cert); - if (cn != null) { - return ImmutableList.of(cn.toLowerCase(Locale.ROOT)); - } - return ImmutableList.of(); - } - - @Nullable - private static String extractCn(X509Certificate cert) { - final String dn = cert.getSubjectX500Principal().getName(); - for (String rdn : dn.split(",")) { - final String trimmed = rdn.trim(); - if (trimmed.toUpperCase(Locale.ROOT).startsWith("CN=")) { - return trimmed.substring(3); - } - } - return null; - } } From f193fce03b3e35c140ba0739478baf48fe215ddf Mon Sep 17 00:00:00 2001 From: jrhee17 Date: Mon, 6 Jul 2026 12:28:42 +0900 Subject: [PATCH 6/6] ai comment --- .../main/java/com/linecorp/armeria/xds/FilterChainMatcher.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xds/src/main/java/com/linecorp/armeria/xds/FilterChainMatcher.java b/xds/src/main/java/com/linecorp/armeria/xds/FilterChainMatcher.java index 017ab89aa63..fe79b64e10b 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/FilterChainMatcher.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/FilterChainMatcher.java @@ -224,7 +224,7 @@ List matchSpecifics(List specifics, Co // e.g., "*.example.com" matches suffix ".example.com" but not ".com". private static boolean hasWildcardServerName(List serverNames, String suffix) { for (String name : serverNames) { - if (name.charAt(0) == '*' && name.length() == suffix.length() + 1 && + if (name.length() == suffix.length() + 1 && name.charAt(0) == '*' && name.endsWith(suffix)) { return true; }