Skip to content

Commit c4c5e7e

Browse files
committed
minimal impl
1 parent f253133 commit c4c5e7e

15 files changed

Lines changed: 1260 additions & 42 deletions

File tree

it/xds-client/src/test/java/com/linecorp/armeria/xds/it/server/ServerFilterChainMatchTest.java

Lines changed: 328 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
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+
17+
package com.linecorp.armeria.xds.it.server;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
21+
import java.nio.file.Path;
22+
23+
import org.junit.jupiter.api.Order;
24+
import org.junit.jupiter.api.Test;
25+
import org.junit.jupiter.api.extension.RegisterExtension;
26+
27+
import com.linecorp.armeria.client.BlockingWebClient;
28+
import com.linecorp.armeria.client.ClientTlsSpec;
29+
import com.linecorp.armeria.client.Endpoint;
30+
import com.linecorp.armeria.client.RequestOptions;
31+
import com.linecorp.armeria.client.WebClient;
32+
import com.linecorp.armeria.common.AggregatedHttpResponse;
33+
import com.linecorp.armeria.common.HttpMethod;
34+
import com.linecorp.armeria.common.HttpRequest;
35+
import com.linecorp.armeria.common.HttpResponse;
36+
import com.linecorp.armeria.common.HttpStatus;
37+
import com.linecorp.armeria.common.SessionProtocol;
38+
import com.linecorp.armeria.server.ServerBuilder;
39+
import com.linecorp.armeria.testing.junit5.server.SelfSignedCertificateExtension;
40+
import com.linecorp.armeria.testing.junit5.server.ServerExtension;
41+
import com.linecorp.armeria.xds.it.XdsCertificateExtension;
42+
import com.linecorp.armeria.xds.it.XdsControlPlaneExtension;
43+
import com.linecorp.armeria.xds.it.XdsResourceReader;
44+
import com.linecorp.armeria.xds.server.XdsServerPlugin;
45+
46+
import io.envoyproxy.envoy.config.listener.v3.Listener;
47+
48+
class ServerTlsSpecSelectorTest {
49+
50+
private static final String LISTENER_NAME = "sni-listener";
51+
52+
@RegisterExtension
53+
@Order(0)
54+
static final XdsCertificateExtension certFoo =
55+
new XdsCertificateExtension(new SelfSignedCertificateExtension("foo.example.com"));
56+
57+
@RegisterExtension
58+
@Order(1)
59+
static final XdsCertificateExtension certBar =
60+
new XdsCertificateExtension(new SelfSignedCertificateExtension("bar.example.com"));
61+
62+
@RegisterExtension
63+
@Order(2)
64+
static final XdsControlPlaneExtension controlPlane = new XdsControlPlaneExtension();
65+
66+
@RegisterExtension
67+
@Order(3)
68+
static final ServerExtension server = new ServerExtension() {
69+
@Override
70+
protected void configure(ServerBuilder sb) {
71+
final Path certPathFoo = certFoo.certificateFile().toPath();
72+
final Path keyPathFoo = certFoo.privateKeyFile().toPath();
73+
final Path certPathBar = certBar.certificateFile().toPath();
74+
final Path keyPathBar = certBar.privateKeyFile().toPath();
75+
76+
//language=YAML
77+
final String yaml =
78+
"""
79+
name: %s
80+
default_filter_chain:
81+
filters:
82+
- name: envoy.filters.network.http_connection_manager
83+
typed_config:
84+
"@type": type.googleapis.com/envoy.extensions.filters\
85+
.network.http_connection_manager.v3.HttpConnectionManager
86+
stat_prefix: ingress_http
87+
route_config:
88+
name: local_route
89+
virtual_hosts:
90+
- name: local_service
91+
domains: ["*"]
92+
routes:
93+
- match:
94+
prefix: "/"
95+
non_forwarding_action: {}
96+
http_filters:
97+
- name: envoy.filters.http.router
98+
transport_socket:
99+
name: envoy.transport_sockets.downstream_tls
100+
typed_config:
101+
"@type": type.googleapis.com/envoy.extensions.transport_sockets\
102+
.tls.v3.DownstreamTlsContext
103+
common_tls_context:
104+
tls_certificates:
105+
- certificate_chain:
106+
filename: "%s"
107+
private_key:
108+
filename: "%s"
109+
- certificate_chain:
110+
filename: "%s"
111+
private_key:
112+
filename: "%s"
113+
""".formatted(LISTENER_NAME, certPathFoo, keyPathFoo, certPathBar, keyPathBar);
114+
controlPlane.set(XdsResourceReader.fromYaml(yaml, Listener.class));
115+
sb.plugin(XdsServerPlugin.of(controlPlane.bootstrap(), LISTENER_NAME));
116+
sb.service("/hello", (ctx, req) -> HttpResponse.of("hello"));
117+
}
118+
};
119+
120+
@Test
121+
void exactSniMatch() {
122+
final int port = server.httpsPort();
123+
124+
// SNI "foo.example.com" → certFoo should be presented.
125+
// Trust only certFoo: if certBar were presented, the handshake would fail.
126+
final ClientTlsSpec fooTlsSpec = ClientTlsSpec.builder()
127+
.trustedCertificates(certFoo.certificate())
128+
.build();
129+
final Endpoint fooEndpoint = Endpoint.of("foo.example.com", port).withIpAddr("127.0.0.1");
130+
final BlockingWebClient fooClient =
131+
WebClient.builder(SessionProtocol.HTTPS, fooEndpoint).build().blocking();
132+
final AggregatedHttpResponse fooRes = fooClient.execute(
133+
HttpRequest.of(HttpMethod.GET, "/hello"),
134+
RequestOptions.builder().clientTlsSpec(fooTlsSpec).build());
135+
assertThat(fooRes.status()).isEqualTo(HttpStatus.OK);
136+
assertThat(fooRes.contentUtf8()).isEqualTo("hello");
137+
138+
// SNI "bar.example.com" → certBar should be presented.
139+
// Trust only certBar: if certFoo were presented, the handshake would fail.
140+
final ClientTlsSpec barTlsSpec = ClientTlsSpec.builder()
141+
.trustedCertificates(certBar.certificate())
142+
.build();
143+
final Endpoint barEndpoint = Endpoint.of("bar.example.com", port).withIpAddr("127.0.0.1");
144+
final BlockingWebClient barClient =
145+
WebClient.builder(SessionProtocol.HTTPS, barEndpoint).build().blocking();
146+
final AggregatedHttpResponse barRes = barClient.execute(
147+
HttpRequest.of(HttpMethod.GET, "/hello"),
148+
RequestOptions.builder().clientTlsSpec(barTlsSpec).build());
149+
assertThat(barRes.status()).isEqualTo(HttpStatus.OK);
150+
assertThat(barRes.contentUtf8()).isEqualTo("hello");
151+
}
152+
153+
@Test
154+
void fallbackToFirstCert() {
155+
// Unknown SNI → first cert (certFoo) should be presented.
156+
// Trust only certFoo, disable hostname verification since the cert CN
157+
// (foo.example.com) won't match the SNI (unknown.example.com).
158+
final ClientTlsSpec tlsSpec = ClientTlsSpec.builder()
159+
.trustedCertificates(certFoo.certificate())
160+
.endpointIdentificationAlgorithm("")
161+
.build();
162+
final int port = server.httpsPort();
163+
final Endpoint endpoint = Endpoint.of("unknown.example.com", port).withIpAddr("127.0.0.1");
164+
final BlockingWebClient client =
165+
WebClient.builder(SessionProtocol.HTTPS, endpoint).build().blocking();
166+
final AggregatedHttpResponse res = client.execute(
167+
HttpRequest.of(HttpMethod.GET, "/hello"),
168+
RequestOptions.builder().clientTlsSpec(tlsSpec).build());
169+
assertThat(res.status()).isEqualTo(HttpStatus.OK);
170+
assertThat(res.contentUtf8()).isEqualTo("hello");
171+
}
172+
173+
@Test
174+
void noSniReturnsFirstCert() {
175+
// Connect by IP (127.0.0.1) — no SNI hostname is sent.
176+
// Server should present the first cert (certFoo).
177+
// Disable hostname verification since the cert CN (foo.example.com) won't match 127.0.0.1.
178+
final ClientTlsSpec tlsSpec = ClientTlsSpec.builder()
179+
.trustedCertificates(certFoo.certificate())
180+
.endpointIdentificationAlgorithm("")
181+
.build();
182+
final BlockingWebClient client = WebClient.of(server.httpsUri()).blocking();
183+
final AggregatedHttpResponse res = client.execute(
184+
HttpRequest.of(HttpMethod.GET, "/hello"),
185+
RequestOptions.builder().clientTlsSpec(tlsSpec).build());
186+
assertThat(res.status()).isEqualTo(HttpStatus.OK);
187+
assertThat(res.contentUtf8()).isEqualTo("hello");
188+
}
189+
}

it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioPodCustomizer.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ public void customizePod(PodBuilder podBuilder) {
4040
// SotW doesn't have per-resource subscriptions, so this doesn't occur.
4141
.addToAnnotations("proxy.istio.io/config",
4242
"{\"proxyMetadata\":{\"ISTIO_DELTA_XDS\":\"false\"}}")
43+
.addToAnnotations("traffic.sidecar.istio.io/excludeOutboundPorts", "8080")
4344
.endMetadata()
4445
.editSpec()
4546
.editMatchingContainer(c -> "test".equals(c.getName()))

it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioServerExtension.java

Lines changed: 47 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@
2020
import java.time.Duration;
2121
import java.util.List;
2222
import java.util.Map;
23+
import java.util.function.Consumer;
2324

2425
import org.junit.jupiter.api.extension.ExtensionContext;
2526
import org.slf4j.Logger;
2627
import org.slf4j.LoggerFactory;
2728

29+
import com.linecorp.armeria.common.annotation.Nullable;
2830
import com.linecorp.armeria.server.ServerConfigurator;
2931

3032
import io.fabric8.kubernetes.api.model.IntOrString;
@@ -63,15 +65,24 @@ public final class IstioServerExtension extends HostOnlyExtension {
6365
private final String serviceName;
6466
private final int port;
6567
private final Class<? extends ServerConfigurator> configuratorClass;
68+
@Nullable
69+
private final Consumer<DeploymentBuilder> deploymentCustomizer;
6670

6771
public IstioServerExtension(String serviceName, int port,
6872
Class<? extends ServerConfigurator> configuratorClass) {
73+
this(serviceName, port, configuratorClass, null);
74+
}
75+
76+
public IstioServerExtension(String serviceName, int port,
77+
Class<? extends ServerConfigurator> configuratorClass,
78+
@Nullable Consumer<DeploymentBuilder> deploymentCustomizer) {
6979
this.serviceName = requireNonNull(serviceName, "serviceName");
7080
if (port <= 0 || port > 65535) {
7181
throw new IllegalArgumentException("port: " + port + " (expected: 1-65535)");
7282
}
7383
this.port = port;
7484
this.configuratorClass = requireNonNull(configuratorClass, "configuratorClass");
85+
this.deploymentCustomizer = deploymentCustomizer;
7586
}
7687

7788
/**
@@ -146,38 +157,43 @@ private void collectServerPodLogs(KubernetesClient client) {
146157

147158
private void createDeployment(KubernetesClient client) {
148159
final Map<String, String> labels = Map.of("app", serviceName);
160+
final DeploymentBuilder builder = new DeploymentBuilder()
161+
.withNewMetadata()
162+
.withName(serviceName)
163+
.withNamespace(NAMESPACE)
164+
.endMetadata()
165+
.withNewSpec()
166+
.withReplicas(1)
167+
.withNewSelector()
168+
.withMatchLabels(labels)
169+
.endSelector()
170+
.withNewTemplate()
171+
.withNewMetadata()
172+
.withLabels(labels)
173+
.withAnnotations(Map.of("sidecar.istio.io/inject", "true"))
174+
.endMetadata()
175+
.withNewSpec()
176+
.addNewContainer()
177+
.withName("server")
178+
.withImage(IstioTestImage.IMAGE_NAME)
179+
.withImagePullPolicy("Never")
180+
.withArgs("--server-factory", configuratorClass.getName(),
181+
"--port", String.valueOf(port))
182+
.addNewEnv()
183+
.withName("JAVA_TOOL_OPTIONS")
184+
.withValue(IstioEnv.podJvmArgs())
185+
.endEnv()
186+
.endContainer()
187+
.endSpec()
188+
.endTemplate()
189+
.endSpec();
190+
191+
if (deploymentCustomizer != null) {
192+
deploymentCustomizer.accept(builder);
193+
}
194+
149195
client.apps().deployments().inNamespace(NAMESPACE)
150-
.resource(new DeploymentBuilder()
151-
.withNewMetadata()
152-
.withName(serviceName)
153-
.withNamespace(NAMESPACE)
154-
.endMetadata()
155-
.withNewSpec()
156-
.withReplicas(1)
157-
.withNewSelector()
158-
.withMatchLabels(labels)
159-
.endSelector()
160-
.withNewTemplate()
161-
.withNewMetadata()
162-
.withLabels(labels)
163-
.withAnnotations(Map.of("sidecar.istio.io/inject", "true"))
164-
.endMetadata()
165-
.withNewSpec()
166-
.addNewContainer()
167-
.withName("server")
168-
.withImage(IstioTestImage.IMAGE_NAME)
169-
.withImagePullPolicy("Never")
170-
.withArgs("--server-factory", configuratorClass.getName(),
171-
"--port", String.valueOf(port))
172-
.addNewEnv()
173-
.withName("JAVA_TOOL_OPTIONS")
174-
.withValue(IstioEnv.podJvmArgs())
175-
.endEnv()
176-
.endContainer()
177-
.endSpec()
178-
.endTemplate()
179-
.endSpec()
180-
.build())
196+
.resource(builder.build())
181197
.create();
182198
logger.info("Created deployment '{}' with server-factory '{}'",
183199
serviceName, configuratorClass.getName());

0 commit comments

Comments
 (0)