diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/CredentialInjectorFilterTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/CredentialInjectorFilterTest.java new file mode 100644 index 00000000000..26e98686237 --- /dev/null +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/CredentialInjectorFilterTest.java @@ -0,0 +1,567 @@ +/* + * Copyright 2025 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; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; + +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.client.BlockingWebClient; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.grpc.GrpcService; +import com.linecorp.armeria.testing.junit5.common.EventLoopExtension; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; +import com.linecorp.armeria.xds.XdsBootstrap; +import com.linecorp.armeria.xds.client.endpoint.XdsHttpPreprocessor; + +import io.envoyproxy.controlplane.cache.v3.SimpleCache; +import io.envoyproxy.controlplane.cache.v3.Snapshot; +import io.envoyproxy.controlplane.server.V3DiscoveryServer; +import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap; +import io.envoyproxy.envoy.config.cluster.v3.Cluster; +import io.envoyproxy.envoy.config.endpoint.v3.ClusterLoadAssignment; +import io.envoyproxy.envoy.config.listener.v3.Listener; +import io.envoyproxy.envoy.config.route.v3.RouteConfiguration; +import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.Secret; + +class CredentialInjectorFilterTest { + + private static final String GROUP = "key"; + private static final SimpleCache cache = new SimpleCache<>(node -> GROUP); + private static final String CLUSTER_NAME = "cluster1"; + private static final String LISTENER_NAME = "listener1"; + private static final String ROUTE_NAME = "route1"; + private static final String BOOTSTRAP_CLUSTER_NAME = "bootstrap-cluster"; + + @RegisterExtension + static final ServerExtension controlPlane = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + final V3DiscoveryServer v3DiscoveryServer = new V3DiscoveryServer(cache); + sb.service(GrpcService.builder() + .addService(v3DiscoveryServer.getAggregatedDiscoveryServiceImpl()) + .addService(v3DiscoveryServer.getListenerDiscoveryServiceImpl()) + .addService(v3DiscoveryServer.getClusterDiscoveryServiceImpl()) + .addService(v3DiscoveryServer.getRouteDiscoveryServiceImpl()) + .addService(v3DiscoveryServer.getEndpointDiscoveryServiceImpl()) + .addService(v3DiscoveryServer.getSecretDiscoveryServiceImpl()) + .build()); + sb.http(0); + } + }; + + @RegisterExtension + static final ServerExtension echoServer = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + // Returns the value of the Authorization header, or "no-auth" if absent + sb.service("/echo-auth", (ctx, req) -> { + final String auth = req.headers().get("authorization"); + return HttpResponse.of(auth != null ? auth : "no-auth"); + }); + sb.service("/echo-custom", (ctx, req) -> { + final String custom = req.headers().get("x-custom-auth"); + return HttpResponse.of(custom != null ? custom : "no-auth"); + }); + sb.http(0); + } + }; + + @RegisterExtension + static final EventLoopExtension eventLoop = new EventLoopExtension(); + + @BeforeEach + void beforeEach() { + final Cluster cluster = XdsResourceReader.fromYaml(""" + name: %s + type: EDS + connect_timeout: 1s + eds_cluster_config: + eds_config: + ads: {} + """.formatted(CLUSTER_NAME), Cluster.class); + + final ClusterLoadAssignment assignment = XdsResourceReader.fromYaml(""" + cluster_name: %s + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: %s + port_value: %s + """.formatted(CLUSTER_NAME, + echoServer.httpSocketAddress().getHostString(), + echoServer.httpPort()), ClusterLoadAssignment.class); + + final Listener listener = XdsResourceReader.fromYaml(""" + name: %s + api_listener: + api_listener: + "@type": type.googleapis.com/envoy.extensions.filters.network\ + .http_connection_manager.v3.HttpConnectionManager + stat_prefix: http + rds: + route_config_name: %s + config_source: + ads: {} + http_filters: + - name: envoy.filters.http.credential_injector + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http\ + .credential_injector.v3.CredentialInjector + overwrite: true + credential: + name: generic_credential + typed_config: + "@type": type.googleapis.com/envoy.extensions.http\ + .injected_credentials.generic.v3.Generic + credential: + name: my-credential + header: authorization + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + """.formatted(LISTENER_NAME, ROUTE_NAME), Listener.class); + + final RouteConfiguration route = XdsResourceReader.fromYaml(""" + name: %s + virtual_hosts: + - name: local_service + domains: [ "*" ] + routes: + - match: + prefix: / + route: + cluster: %s + """.formatted(ROUTE_NAME, CLUSTER_NAME), RouteConfiguration.class); + + cache.setSnapshot(GROUP, Snapshot.create( + ImmutableList.of(cluster), + ImmutableList.of(assignment), + ImmutableList.of(listener), + ImmutableList.of(route), + ImmutableList.of(), + "1")); + } + + @Test + void credentialInjectedIntoAuthorizationHeader() { + final Bootstrap bootstrap = XdsResourceReader.fromYaml(bootstrapWithSecret( + "Bearer mySecretToken")); + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap, eventLoop.get()); + XdsHttpPreprocessor preprocessor = + XdsHttpPreprocessor.ofListener(LISTENER_NAME, xdsBootstrap)) { + final BlockingWebClient client = WebClient.of(preprocessor).blocking(); + final AggregatedHttpResponse response = client.get("/echo-auth"); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).isEqualTo("Bearer mySecretToken"); + } + } + + @Test + void noCredentialWithAllowWithoutCredentialFalseReturns401() { + // Use a listener with allow_request_without_credential=false and an empty secret + cache.setSnapshot(GROUP, Snapshot.create( + cache.getSnapshot(GROUP).clusters().resources().values().stream().toList(), + cache.getSnapshot(GROUP).endpoints().resources().values().stream().toList(), + ImmutableList.of(listenerWithConfig(false, true)), + cache.getSnapshot(GROUP).routes().resources().values().stream().toList(), + ImmutableList.of(), + "2")); + + final Bootstrap bootstrap = XdsResourceReader.fromYaml(bootstrapWithEmptySecret()); + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap, eventLoop.get()); + XdsHttpPreprocessor preprocessor = + XdsHttpPreprocessor.ofListener(LISTENER_NAME, xdsBootstrap)) { + final BlockingWebClient client = WebClient.of(preprocessor).blocking(); + final AggregatedHttpResponse response = client.get("/echo-auth"); + assertThat(response.status()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @Test + void noCredentialWithAllowWithoutCredentialTruePassesThrough() { + cache.setSnapshot(GROUP, Snapshot.create( + cache.getSnapshot(GROUP).clusters().resources().values().stream().toList(), + cache.getSnapshot(GROUP).endpoints().resources().values().stream().toList(), + ImmutableList.of(listenerWithConfig(true, true)), + cache.getSnapshot(GROUP).routes().resources().values().stream().toList(), + ImmutableList.of(), + "3")); + + final Bootstrap bootstrap = XdsResourceReader.fromYaml(bootstrapWithEmptySecret()); + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap, eventLoop.get()); + XdsHttpPreprocessor preprocessor = + XdsHttpPreprocessor.ofListener(LISTENER_NAME, xdsBootstrap)) { + final BlockingWebClient client = WebClient.of(preprocessor).blocking(); + final AggregatedHttpResponse response = client.get("/echo-auth"); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).isEqualTo("no-auth"); + } + } + + @Test + void overwriteFalsePreservesExistingHeader() { + // Use a listener with overwrite=false + cache.setSnapshot(GROUP, Snapshot.create( + cache.getSnapshot(GROUP).clusters().resources().values().stream().toList(), + cache.getSnapshot(GROUP).endpoints().resources().values().stream().toList(), + ImmutableList.of(listenerWithConfig(false, false)), + cache.getSnapshot(GROUP).routes().resources().values().stream().toList(), + ImmutableList.of(), + "4")); + + final Bootstrap bootstrap = XdsResourceReader.fromYaml( + bootstrapWithSecret("Bearer newToken")); + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap, eventLoop.get()); + XdsHttpPreprocessor preprocessor = + XdsHttpPreprocessor.ofListener(LISTENER_NAME, xdsBootstrap)) { + final BlockingWebClient client = WebClient.of(preprocessor).blocking(); + final AggregatedHttpResponse response = client.prepare() + .get("/echo-auth") + .header("authorization", "Bearer existingToken") + .execute(); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + // overwrite=false → existing header preserved + assertThat(response.contentUtf8()).isEqualTo("Bearer existingToken"); + } + } + + @Test + void customHeaderName() { + // Use a listener with custom header name + cache.setSnapshot(GROUP, Snapshot.create( + cache.getSnapshot(GROUP).clusters().resources().values().stream().toList(), + cache.getSnapshot(GROUP).endpoints().resources().values().stream().toList(), + ImmutableList.of(listenerWithCustomHeader("x-custom-auth")), + cache.getSnapshot(GROUP).routes().resources().values().stream().toList(), + ImmutableList.of(), + "5")); + + final Bootstrap bootstrap = XdsResourceReader.fromYaml( + bootstrapWithSecret("Basic dXNlcjpwYXNz")); + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap, eventLoop.get()); + XdsHttpPreprocessor preprocessor = + XdsHttpPreprocessor.ofListener(LISTENER_NAME, xdsBootstrap)) { + final BlockingWebClient client = WebClient.of(preprocessor).blocking(); + final AggregatedHttpResponse response = client.get("/echo-custom"); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).isEqualTo("Basic dXNlcjpwYXNz"); + } + } + + private Listener listenerWithConfig(boolean allowWithoutCredential, boolean overwrite) { + return XdsResourceReader.fromYaml(""" + name: %s + api_listener: + api_listener: + "@type": type.googleapis.com/envoy.extensions.filters.network\ + .http_connection_manager.v3.HttpConnectionManager + stat_prefix: http + rds: + route_config_name: %s + config_source: + ads: {} + http_filters: + - name: envoy.filters.http.credential_injector + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http\ + .credential_injector.v3.CredentialInjector + overwrite: %s + allow_request_without_credential: %s + credential: + name: generic_credential + typed_config: + "@type": type.googleapis.com/envoy.extensions.http\ + .injected_credentials.generic.v3.Generic + credential: + name: my-credential + header: authorization + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + """.formatted(LISTENER_NAME, ROUTE_NAME, overwrite, allowWithoutCredential), + Listener.class); + } + + private Listener listenerWithCustomHeader(String headerName) { + return XdsResourceReader.fromYaml(""" + name: %s + api_listener: + api_listener: + "@type": type.googleapis.com/envoy.extensions.filters.network\ + .http_connection_manager.v3.HttpConnectionManager + stat_prefix: http + rds: + route_config_name: %s + config_source: + ads: {} + http_filters: + - name: envoy.filters.http.credential_injector + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http\ + .credential_injector.v3.CredentialInjector + overwrite: true + credential: + name: generic_credential + typed_config: + "@type": type.googleapis.com/envoy.extensions.http\ + .injected_credentials.generic.v3.Generic + credential: + name: my-credential + header: %s + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + """.formatted(LISTENER_NAME, ROUTE_NAME, headerName), Listener.class); + } + + private String bootstrapWithSecret(String credentialValue) { + return """ + dynamic_resources: + ads_config: + api_type: GRPC + grpc_services: + - envoy_grpc: + cluster_name: %s + cds_config: + ads: {} + lds_config: + ads: {} + static_resources: + clusters: + - name: %s + type: STATIC + load_assignment: + cluster_name: %s + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: %s + port_value: %s + secrets: + - name: my-credential + generic_secret: + secret: + inline_string: "%s" + """.formatted(BOOTSTRAP_CLUSTER_NAME, + BOOTSTRAP_CLUSTER_NAME, BOOTSTRAP_CLUSTER_NAME, + controlPlane.httpSocketAddress().getHostString(), + controlPlane.httpPort(), + credentialValue); + } + + private String bootstrapWithEmptySecret() { + return """ + dynamic_resources: + ads_config: + api_type: GRPC + grpc_services: + - envoy_grpc: + cluster_name: %s + cds_config: + ads: {} + lds_config: + ads: {} + static_resources: + clusters: + - name: %s + type: STATIC + load_assignment: + cluster_name: %s + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: %s + port_value: %s + secrets: + - name: my-credential + generic_secret: + secret: + inline_string: "" + """.formatted(BOOTSTRAP_CLUSTER_NAME, + BOOTSTRAP_CLUSTER_NAME, BOOTSTRAP_CLUSTER_NAME, + controlPlane.httpSocketAddress().getHostString(), + controlPlane.httpPort()); + } + + @Test + void credentialFetchedViaSds() { + // Push a generic_secret to the control plane via SDS + final Secret secret = XdsResourceReader.fromYaml(""" + name: my-credential + generic_secret: + secret: + inline_string: "Bearer sdsToken123" + """, Secret.class); + + // Use a listener whose credential_injector has sds_config: ads: {} + final Listener sdsListener = XdsResourceReader.fromYaml(""" + name: %s + api_listener: + api_listener: + "@type": type.googleapis.com/envoy.extensions.filters.network\ + .http_connection_manager.v3.HttpConnectionManager + stat_prefix: http + rds: + route_config_name: %s + config_source: + ads: {} + http_filters: + - name: envoy.filters.http.credential_injector + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http\ + .credential_injector.v3.CredentialInjector + overwrite: true + credential: + name: generic_credential + typed_config: + "@type": type.googleapis.com/envoy.extensions.http\ + .injected_credentials.generic.v3.Generic + credential: + name: my-credential + sds_config: + ads: {} + header: authorization + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + """.formatted(LISTENER_NAME, ROUTE_NAME), Listener.class); + + cache.setSnapshot(GROUP, Snapshot.create( + cache.getSnapshot(GROUP).clusters().resources().values().stream().toList(), + cache.getSnapshot(GROUP).endpoints().resources().values().stream().toList(), + ImmutableList.of(sdsListener), + cache.getSnapshot(GROUP).routes().resources().values().stream().toList(), + ImmutableList.of(secret), + "6")); + + // Bootstrap without static secrets — credential comes from SDS + final Bootstrap bootstrap = XdsResourceReader.fromYaml(bootstrapWithoutSecrets()); + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap, eventLoop.get()); + XdsHttpPreprocessor preprocessor = + XdsHttpPreprocessor.ofListener(LISTENER_NAME, xdsBootstrap)) { + final BlockingWebClient client = WebClient.of(preprocessor).blocking(); + final AggregatedHttpResponse response = client.get("/echo-auth"); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).isEqualTo("Bearer sdsToken123"); + } + } + + @Test + void credentialFromFile(@TempDir Path tempDir) throws IOException { + final Path credentialFile = tempDir.resolve("credential.txt"); + Files.writeString(credentialFile, "Bearer fileToken456"); + + final Bootstrap bootstrap = XdsResourceReader.fromYaml( + bootstrapWithFileSecret(credentialFile.toAbsolutePath().toString())); + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap, eventLoop.get()); + XdsHttpPreprocessor preprocessor = + XdsHttpPreprocessor.ofListener(LISTENER_NAME, xdsBootstrap)) { + final BlockingWebClient client = WebClient.of(preprocessor).blocking(); + final AggregatedHttpResponse response = client.get("/echo-auth"); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).isEqualTo("Bearer fileToken456"); + } + } + + private String bootstrapWithFileSecret(String filePath) { + return """ + dynamic_resources: + ads_config: + api_type: GRPC + grpc_services: + - envoy_grpc: + cluster_name: %s + cds_config: + ads: {} + lds_config: + ads: {} + static_resources: + clusters: + - name: %s + type: STATIC + load_assignment: + cluster_name: %s + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: %s + port_value: %s + secrets: + - name: my-credential + generic_secret: + secret: + filename: '%s' + """.formatted(BOOTSTRAP_CLUSTER_NAME, + BOOTSTRAP_CLUSTER_NAME, BOOTSTRAP_CLUSTER_NAME, + controlPlane.httpSocketAddress().getHostString(), + controlPlane.httpPort(), + filePath); + } + + private String bootstrapWithoutSecrets() { + return """ + dynamic_resources: + ads_config: + api_type: GRPC + grpc_services: + - envoy_grpc: + cluster_name: %s + cds_config: + ads: {} + lds_config: + ads: {} + static_resources: + clusters: + - name: %s + type: STATIC + load_assignment: + cluster_name: %s + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: %s + port_value: %s + """.formatted(BOOTSTRAP_CLUSTER_NAME, + BOOTSTRAP_CLUSTER_NAME, BOOTSTRAP_CLUSTER_NAME, + controlPlane.httpSocketAddress().getHostString(), + controlPlane.httpPort()); + } +} diff --git a/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/filter/IstioFilterFactories.java b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/filter/IstioFilterFactories.java index c5ff6851416..2ef483a509d 100644 --- a/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/filter/IstioFilterFactories.java +++ b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/filter/IstioFilterFactories.java @@ -21,7 +21,7 @@ import com.linecorp.armeria.client.HttpPreprocessor; import com.linecorp.armeria.common.annotation.Nullable; -import com.linecorp.armeria.xds.XdsResourceValidator; +import com.linecorp.armeria.xds.filter.FactoryContext; import com.linecorp.armeria.xds.filter.HttpFilterFactory; import com.linecorp.armeria.xds.filter.XdsHttpFilter; import com.linecorp.armeria.xds.internal.XdsCommonUtil; @@ -40,7 +40,7 @@ public final class IstioFilterFactories { public abstract static class Base implements HttpFilterFactory { @Override @Nullable - public XdsHttpFilter create(HttpFilter httpFilter, Any config, XdsResourceValidator validator) { + public XdsHttpFilter create(HttpFilter httpFilter, Any config, FactoryContext context) { return null; } } @@ -62,7 +62,7 @@ public String name() { } @Override - public XdsHttpFilter create(HttpFilter httpFilter, Any config, XdsResourceValidator validator) { + public XdsHttpFilter create(HttpFilter httpFilter, Any config, FactoryContext context) { // we need to also generate istio proto configs to actually dynamically decide alpn // based on filter config, but for testing purposes we just assume h2 is always used return ALPN_FILTER; diff --git a/xds-api/src/main/proto/envoy/config/core/v3/extension.proto b/xds-api/src/main/proto/envoy/config/core/v3/extension.proto index cacc7b00c21..9602a1e719f 100644 --- a/xds-api/src/main/proto/envoy/config/core/v3/extension.proto +++ b/xds-api/src/main/proto/envoy/config/core/v3/extension.proto @@ -4,6 +4,7 @@ package envoy.config.core.v3; import "google/protobuf/any.proto"; +import "armeria/xds/supported.proto"; import "udpa/annotations/status.proto"; import "validate/validate.proto"; @@ -20,6 +21,7 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; message TypedExtensionConfig { // The name of an extension. This is not used to select the extension, instead // it serves the role of an opaque identifier. + option (armeria.xds.supported.field) = 1; string name = 1 [(validate.rules).string = {min_len: 1}]; // The typed config for the extension. The type URL will be used to identify @@ -28,5 +30,6 @@ message TypedExtensionConfig { // URL of ``TypedStruct`` will be utilized. See the // :ref:`extension configuration overview // ` for further details. + option (armeria.xds.supported.field) = 2; google.protobuf.Any typed_config = 2 [(validate.rules).any = {required: true}]; } diff --git a/xds-api/src/main/proto/envoy/extensions/filters/http/credential_injector/v3/credential_injector.proto b/xds-api/src/main/proto/envoy/extensions/filters/http/credential_injector/v3/credential_injector.proto index 452a3f71d12..6201652f981 100644 --- a/xds-api/src/main/proto/envoy/extensions/filters/http/credential_injector/v3/credential_injector.proto +++ b/xds-api/src/main/proto/envoy/extensions/filters/http/credential_injector/v3/credential_injector.proto @@ -4,6 +4,7 @@ package envoy.extensions.filters.http.credential_injector.v3; import "envoy/config/core/v3/extension.proto"; +import "armeria/xds/supported.proto"; import "udpa/annotations/status.proto"; import "validate/validate.proto"; @@ -71,6 +72,7 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; message CredentialInjector { // Whether to overwrite the value or not if the injected headers already exist. // Value defaults to false. + option (armeria.xds.supported.field) = 1; bool overwrite = 1; // Whether to send the request to upstream if the credential is not present or if the credential injection @@ -79,9 +81,11 @@ message CredentialInjector { // By default, a request will fail with ``401 Unauthorized`` if the // credential is not present or the injection of the credential to the request fails. // If set to true, the request will be sent to upstream without the credential. + option (armeria.xds.supported.field) = 2; bool allow_request_without_credential = 2; // The credential to inject into the proxied requests // [#extension-category: envoy.http.injected_credentials] + option (armeria.xds.supported.field) = 3; config.core.v3.TypedExtensionConfig credential = 3 [(validate.rules).message = {required: true}]; } diff --git a/xds-api/src/main/proto/envoy/extensions/http/injected_credentials/generic/v3/generic.proto b/xds-api/src/main/proto/envoy/extensions/http/injected_credentials/generic/v3/generic.proto index e6c3bfaf5fd..031eaa6fb39 100644 --- a/xds-api/src/main/proto/envoy/extensions/http/injected_credentials/generic/v3/generic.proto +++ b/xds-api/src/main/proto/envoy/extensions/http/injected_credentials/generic/v3/generic.proto @@ -4,6 +4,7 @@ package envoy.extensions.http.injected_credentials.generic.v3; import "envoy/extensions/transport_sockets/tls/v3/secret.proto"; +import "armeria/xds/supported.proto"; import "udpa/annotations/status.proto"; import "validate/validate.proto"; @@ -24,11 +25,13 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; message Generic { // The SDS configuration for the credential that will be injected to the specified HTTP request header. // It must be a generic secret. + option (armeria.xds.supported.field) = 1; transport_sockets.tls.v3.SdsSecretConfig credential = 1 [(validate.rules).message = {required: true}]; // The header that will be injected to the HTTP request with the provided credential. // If not set, filter will default to: ``Authorization`` + option (armeria.xds.supported.field) = 2; string header = 2 [(validate.rules).string = {well_known_regex: HTTP_HEADER_NAME ignore_empty: true}]; diff --git a/xds-api/src/main/proto/envoy/extensions/transport_sockets/tls/v3/secret.proto b/xds-api/src/main/proto/envoy/extensions/transport_sockets/tls/v3/secret.proto index 9f40df09c56..2bbe733956b 100644 --- a/xds-api/src/main/proto/envoy/extensions/transport_sockets/tls/v3/secret.proto +++ b/xds-api/src/main/proto/envoy/extensions/transport_sockets/tls/v3/secret.proto @@ -26,6 +26,7 @@ message GenericSecret { // Secret of generic type and is available to filters. It is expected // that only only one of secret and secrets is set. + option (armeria.xds.supported.field) = 1; config.core.v3.DataSource secret = 1 [(udpa.annotations.sensitive) = true]; // For cases where multiple associated secrets need to be distributed together. It is expected @@ -63,6 +64,7 @@ message Secret { option (armeria.xds.supported.oneof_field) = 4; CertificateValidationContext validation_context = 4; + option (armeria.xds.supported.oneof_field) = 5; GenericSecret generic_secret = 5; } } diff --git a/xds/src/main/java/com/linecorp/armeria/xds/FilterUtil.java b/xds/src/main/java/com/linecorp/armeria/xds/FilterUtil.java index 1914e37d91c..3e4d9ea2b33 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/FilterUtil.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/FilterUtil.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.Map; +import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.protobuf.Any; @@ -29,8 +30,10 @@ import com.linecorp.armeria.client.ClientPreprocessors; import com.linecorp.armeria.client.ClientPreprocessorsBuilder; import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.xds.filter.FactoryContext; import com.linecorp.armeria.xds.filter.HttpFilterFactory; import com.linecorp.armeria.xds.filter.XdsHttpFilter; +import com.linecorp.armeria.xds.stream.SnapshotStream; import io.envoyproxy.envoy.config.route.v3.RetryPolicy; import io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.HttpFilter; @@ -47,51 +50,77 @@ static Map mergeFilterConfigs( .buildKeepingLast(); } - static ClientPreprocessors buildDownstreamFilter( - XdsExtensionRegistry extensionRegistry, + static SnapshotStream buildDownstreamFilter( + XdsExtensionRegistry extensionRegistry, FactoryContext factoryContext, List httpFilters, Map filterConfigs) { if (httpFilters.isEmpty()) { - return ClientPreprocessors.of(); + return SnapshotStream.just(ClientPreprocessors.of()); } - final ClientPreprocessorsBuilder builder = ClientPreprocessors.builder(); + final ImmutableList.Builder> streams = ImmutableList.builder(); for (int i = httpFilters.size() - 1; i >= 0; i--) { final HttpFilter httpFilter = httpFilters.get(i); final Any perRouteConfig = filterConfigs.get(httpFilter.getName()); - final XdsHttpFilter instance = resolveInstance(extensionRegistry, httpFilter, perRouteConfig); - if (instance == null) { - continue; + final SnapshotStream stream = + resolveInstance(extensionRegistry, factoryContext, httpFilter, perRouteConfig); + if (stream != null) { + streams.add(stream); } - builder.add(instance.httpPreprocessor()); - builder.addRpc(instance.rpcPreprocessor()); } - return builder.build(); + final ImmutableList> streamList = streams.build(); + if (streamList.isEmpty()) { + return SnapshotStream.just(ClientPreprocessors.of()); + } + return SnapshotStream.combineNLatest(streamList).map(filters -> { + final ClientPreprocessorsBuilder builder = ClientPreprocessors.builder(); + for (XdsHttpFilter f : filters) { + builder.add(f.httpPreprocessor()); + builder.addRpc(f.rpcPreprocessor()); + } + return builder.build(); + }); } - static ClientDecoration buildUpstreamFilter( - XdsExtensionRegistry extensionRegistry, + static SnapshotStream buildUpstreamFilter( + XdsExtensionRegistry extensionRegistry, FactoryContext factoryContext, List httpFilters, Map filterConfigs, @Nullable RetryPolicy retryPolicy) { - final ClientDecorationBuilder builder = ClientDecoration.builder(); + final ImmutableList.Builder> streams = ImmutableList.builder(); for (int i = httpFilters.size() - 1; i >= 0; i--) { final HttpFilter httpFilter = httpFilters.get(i); final Any perRouteConfig = filterConfigs.get(httpFilter.getName()); - final XdsHttpFilter instance = resolveInstance(extensionRegistry, httpFilter, perRouteConfig); - if (instance == null) { - continue; + final SnapshotStream stream = + resolveInstance(extensionRegistry, factoryContext, httpFilter, perRouteConfig); + if (stream != null) { + streams.add(stream); + } + } + final ImmutableList> streamList = streams.build(); + if (streamList.isEmpty()) { + return SnapshotStream.just(buildDecoration(null, retryPolicy)); + } + return SnapshotStream.combineNLatest(streamList).map(filters -> { + return buildDecoration(filters, retryPolicy); + }); + } + + private static ClientDecoration buildDecoration(@Nullable List filters, + @Nullable RetryPolicy retryPolicy) { + final ClientDecorationBuilder builder = ClientDecoration.builder(); + if (filters != null) { + for (XdsHttpFilter f : filters) { + builder.add(f.httpDecorator()); + builder.addRpc(f.rpcDecorator()); } - builder.add(instance.httpDecorator()); - builder.addRpc(instance.rpcDecorator()); } if (retryPolicy != null) { - // add the retrying decorator as the first (outermost) decorator if exists builder.add(new RetryStateFactory(retryPolicy).retryingDecorator()); } return builder.build(); } @Nullable - static XdsHttpFilter resolveInstance( - XdsExtensionRegistry extensionRegistry, + static SnapshotStream resolveInstance( + XdsExtensionRegistry extensionRegistry, FactoryContext factoryContext, HttpFilter httpFilter, @Nullable Any perRouteConfig) { final Any defaultConfig = httpFilter.getTypedConfig(); final Any filterConfig = perRouteConfig != null ? perRouteConfig : defaultConfig; @@ -110,7 +139,8 @@ static XdsHttpFilter resolveInstance( httpFilter.getConfigTypeCase() == ConfigTypeCase.CONFIGTYPE_NOT_SET, "Only 'typed_config' is supported, but '%s' was supplied", httpFilter.getConfigTypeCase()); - return factory.create(httpFilter, filterConfig, extensionRegistry.validator()); + return factory.createStream(httpFilter, filterConfig, factoryContext) + .rescheduleEventsOn(factoryContext.eventLoop()); } private FilterUtil() {} diff --git a/xds/src/main/java/com/linecorp/armeria/xds/GenericSecretSnapshot.java b/xds/src/main/java/com/linecorp/armeria/xds/GenericSecretSnapshot.java new file mode 100644 index 00000000000..ef6303f6599 --- /dev/null +++ b/xds/src/main/java/com/linecorp/armeria/xds/GenericSecretSnapshot.java @@ -0,0 +1,87 @@ +/* + * Copyright 2025 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 com.google.common.base.MoreObjects; +import com.google.common.base.Objects; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; + +import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.GenericSecret; + +/** + * A snapshot of a {@link GenericSecret} resource with its resolved credential value. + * This snapshot is created when resolving generic secret configuration from xDS resources, + * analogous to {@link TlsCertificateSnapshot} for TLS certificates. + */ +@UnstableApi +public final class GenericSecretSnapshot implements Snapshot { + + private final GenericSecret resource; + @Nullable + private final String credential; + + GenericSecretSnapshot(GenericSecret resource, @Nullable String credential) { + this.resource = resource; + this.credential = credential; + } + + /** + * Returns the resolved credential value, or {@code null} if the credential + * is not available or empty. + */ + @Nullable + public String credential() { + return credential; + } + + @Override + public GenericSecret xdsResource() { + return resource; + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (object == null || getClass() != object.getClass()) { + return false; + } + final GenericSecretSnapshot that = (GenericSecretSnapshot) object; + return Objects.equal(resource, that.resource); + } + + @Override + public int hashCode() { + return Objects.hashCode(resource); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .toString(); + } + + @Override + public String toDebugString() { + return MoreObjects.toStringHelper(this) + .add("genericSecret", resource) + .toString(); + } +} diff --git a/xds/src/main/java/com/linecorp/armeria/xds/GenericSecretStream.java b/xds/src/main/java/com/linecorp/armeria/xds/GenericSecretStream.java new file mode 100644 index 00000000000..e1d209290f5 --- /dev/null +++ b/xds/src/main/java/com/linecorp/armeria/xds/GenericSecretStream.java @@ -0,0 +1,69 @@ +/* + * Copyright 2025 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.Optional; + +import com.google.protobuf.ByteString; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.xds.stream.RefCountedStream; +import com.linecorp.armeria.xds.stream.SnapshotStream; +import com.linecorp.armeria.xds.stream.Subscription; + +import io.envoyproxy.envoy.config.core.v3.WatchedDirectory; +import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.GenericSecret; + +final class GenericSecretStream extends RefCountedStream { + + private final SubscriptionContext context; + private final SecretXdsResource resource; + + GenericSecretStream(SubscriptionContext context, SecretXdsResource resource) { + this.context = context; + this.resource = resource; + } + + @Override + protected Subscription onStart(SnapshotWatcher watcher) { + final GenericSecret genericSecret = resource.resource().getGenericSecret(); + if (!genericSecret.hasSecret()) { + final SnapshotStream errorStream = SnapshotStream.error( + new XdsResourceException(XdsType.SECRET, resource.name(), + "GenericSecret does not contain a secret data source")); + return errorStream.subscribe(watcher); + } + final DataSourceStream dataSourceStream = + new DataSourceStream(genericSecret.getSecret(), + WatchedDirectory.getDefaultInstance(), context); + final SnapshotStream stream = + dataSourceStream.map(optBytes -> { + final String credential = toCredentialValue(optBytes); + return new GenericSecretSnapshot(genericSecret, credential); + }); + return stream.subscribe(watcher); + } + + @Nullable + private static String toCredentialValue(Optional optBytes) { + if (!optBytes.isPresent()) { + return null; + } + final String value = optBytes.get().toStringUtf8(); + return value.isEmpty() ? null : value; + } +} diff --git a/xds/src/main/java/com/linecorp/armeria/xds/ListenerManager.java b/xds/src/main/java/com/linecorp/armeria/xds/ListenerManager.java index 5d7026061d4..af0b8c11b6d 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/ListenerManager.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/ListenerManager.java @@ -71,7 +71,8 @@ void register(Listener listener, SubscriptionContext context, SnapshotWatcher watcher.onUpdate(null, maybeWrap(XdsType.LISTENER, listener.getName(), t)))); } - Subscription register(String name, SubscriptionContext context, SnapshotWatcher watcher) { + Subscription register(String name, SubscriptionContext context, + SnapshotWatcher watcher) { if (closed) { return Subscription.noop(); } diff --git a/xds/src/main/java/com/linecorp/armeria/xds/ListenerResourceParser.java b/xds/src/main/java/com/linecorp/armeria/xds/ListenerResourceParser.java index 9cb17840414..023d787dd53 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/ListenerResourceParser.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/ListenerResourceParser.java @@ -21,15 +21,14 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.google.common.collect.ImmutableList; import com.google.protobuf.Any; import com.linecorp.armeria.common.annotation.Nullable; -import com.linecorp.armeria.xds.filter.XdsHttpFilter; import io.envoyproxy.envoy.config.listener.v3.Filter; import io.envoyproxy.envoy.config.listener.v3.FilterChain; import io.envoyproxy.envoy.config.listener.v3.Listener; +import io.envoyproxy.envoy.extensions.filters.http.router.v3.Router; import io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager; import io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.HttpFilter; @@ -43,25 +42,36 @@ final class ListenerResourceParser extends ResourceParser resolveDownstreamFilters( - @Nullable HttpConnectionManager connectionManager, - XdsExtensionRegistry registry) { + @Nullable + private static Router findRouter(@Nullable HttpConnectionManager connectionManager, + XdsExtensionRegistry registry) { if (connectionManager == null) { - return ImmutableList.of(); + return null; } final List httpFilters = connectionManager.getHttpFiltersList(); - final ImmutableList.Builder builder = ImmutableList.builder(); - for (HttpFilter httpFilter : httpFilters) { - final XdsHttpFilter instance = FilterUtil.resolveInstance(registry, httpFilter, null); - if (instance != null) { - builder.add(instance); - } + if (httpFilters.isEmpty()) { + return null; + } + final HttpFilter last = httpFilters.get(httpFilters.size() - 1); + if (!ROUTER_FILTER_NAME.equals(last.getName())) { + return null; + } + final Any typedConfig = last.getTypedConfig(); + if (typedConfig == Any.getDefaultInstance()) { + return Router.getDefaultInstance(); + } + if (!ROUTER_TYPE_URL.equals(typedConfig.getTypeUrl())) { + return null; } - return builder.build(); + return registry.unpack(typedConfig, Router.class); } @Nullable @@ -107,8 +117,8 @@ private static HttpConnectionManager findHcmInFilterChain(FilterChain filterChai @Override ListenerXdsResource parse(Listener message, XdsExtensionRegistry registry, String version) { final HttpConnectionManager connectionManager = findHcm(message, registry); - final List downstreamFilters = resolveDownstreamFilters(connectionManager, registry); - return new ListenerXdsResource(message, connectionManager, downstreamFilters, version); + final Router router = findRouter(connectionManager, registry); + return new ListenerXdsResource(message, connectionManager, router, version); } @Override diff --git a/xds/src/main/java/com/linecorp/armeria/xds/ListenerXdsResource.java b/xds/src/main/java/com/linecorp/armeria/xds/ListenerXdsResource.java index 12b61e61d49..ff850d72183 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/ListenerXdsResource.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/ListenerXdsResource.java @@ -16,12 +16,8 @@ package com.linecorp.armeria.xds; -import java.util.List; - import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.annotation.UnstableApi; -import com.linecorp.armeria.xds.client.endpoint.RouterFilterFactory.RouterXdsHttpFilter; -import com.linecorp.armeria.xds.filter.XdsHttpFilter; import io.envoyproxy.envoy.config.listener.v3.Listener; import io.envoyproxy.envoy.extensions.filters.http.router.v3.Router; @@ -36,35 +32,21 @@ public final class ListenerXdsResource extends AbstractXdsResource { private final Listener listener; @Nullable private final HttpConnectionManager connectionManager; - private final List downstreamFilters; @Nullable private final Router router; ListenerXdsResource(Listener listener, @Nullable HttpConnectionManager connectionManager, - List downstreamFilters, String version) { - this(listener, connectionManager, downstreamFilters, version, 0); + @Nullable Router router, String version) { + this(listener, connectionManager, router, version, 0); } private ListenerXdsResource(Listener listener, @Nullable HttpConnectionManager connectionManager, - List downstreamFilters, + @Nullable Router router, String version, long revision) { super(version, revision); this.listener = listener; this.connectionManager = connectionManager; - this.downstreamFilters = downstreamFilters; - router = findRouter(downstreamFilters); - } - - @Nullable - private static Router findRouter(List filters) { - if (filters.isEmpty()) { - return null; - } - final XdsHttpFilter last = filters.get(filters.size() - 1); - if (last instanceof RouterXdsHttpFilter) { - return ((RouterXdsHttpFilter) last).router(); - } - return null; + this.router = router; } @Override @@ -95,7 +77,7 @@ ListenerXdsResource withRevision(long revision) { if (revision == revision()) { return this; } - return new ListenerXdsResource(listener, connectionManager, downstreamFilters, + return new ListenerXdsResource(listener, connectionManager, router, version(), revision); } diff --git a/xds/src/main/java/com/linecorp/armeria/xds/RouteEntry.java b/xds/src/main/java/com/linecorp/armeria/xds/RouteEntry.java index 2d0272c3b45..86c1d5faf34 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/RouteEntry.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/RouteEntry.java @@ -16,12 +16,10 @@ package com.linecorp.armeria.xds; -import java.util.List; import java.util.Map; import java.util.Objects; import com.google.common.base.MoreObjects; -import com.google.common.collect.ImmutableList; import com.google.protobuf.Any; import com.linecorp.armeria.client.ClientDecoration; @@ -37,7 +35,6 @@ import com.linecorp.armeria.xds.internal.DelegatingHttpClient; import com.linecorp.armeria.xds.internal.DelegatingRpcClient; -import io.envoyproxy.envoy.config.route.v3.RetryPolicy; import io.envoyproxy.envoy.config.route.v3.Route; import io.envoyproxy.envoy.config.route.v3.RouteAction; import io.envoyproxy.envoy.config.route.v3.VirtualHost; @@ -60,50 +57,16 @@ public final class RouteEntry { private final RouteEntryMatcher matcher; RouteEntry(Route route, @Nullable ClusterSnapshot clusterSnapshot, int index, - @Nullable ListenerXdsResource listenerResource, RouteXdsResource routeResource, - VirtualHostXdsResource vhostResource, XdsExtensionRegistry extensionRegistry) { + Map filterConfigs, + ClientPreprocessors downstreamPreprocessors, ClientDecoration upstreamDecoration) { this.route = route; this.clusterSnapshot = clusterSnapshot; this.index = index; + this.filterConfigs = filterConfigs; matcher = new RouteEntryMatcher(route.getMatch()); - // Merge per_filter_config: route-config level < vhost level < route level - final Map routeConfigFilterConfigs = - routeResource.resource().getTypedPerFilterConfigMap(); - final Map vhostFilterConfigs = - vhostResource.resource().getTypedPerFilterConfigMap(); - final Map routeFilterConfigs = - route.getTypedPerFilterConfigMap(); - filterConfigs = FilterUtil.mergeFilterConfigs( - FilterUtil.mergeFilterConfigs(routeConfigFilterConfigs, vhostFilterConfigs), - routeFilterConfigs); - - // Extract upstream HTTP filters from the Router (last filter in HCM filter chain) - final List upstreamFilters; - if (listenerResource != null && listenerResource.router() != null) { - upstreamFilters = listenerResource.router().getUpstreamHttpFiltersList(); - } else { - upstreamFilters = ImmutableList.of(); - } - - // Determine retry policy - final RetryPolicy retryPolicy = route.getRoute().getRetryPolicy(); - final RetryPolicy effectiveRetryPolicy = - retryPolicy == RetryPolicy.getDefaultInstance() ? null : retryPolicy; - final ClientDecoration clientDecoration = FilterUtil.buildUpstreamFilter( - extensionRegistry, upstreamFilters, filterConfigs, effectiveRetryPolicy); - httpClient = clientDecoration.decorate(DelegatingHttpClient.of()); - rpcClient = clientDecoration.rpcDecorate(DelegatingRpcClient.of()); - - // Build downstream filters (HCM http_filters) with per-route config - final List hcmHttpFilters; - if (listenerResource != null && listenerResource.connectionManager() != null) { - hcmHttpFilters = listenerResource.connectionManager().getHttpFiltersList(); - } else { - hcmHttpFilters = ImmutableList.of(); - } - final ClientPreprocessors downstreamPreprocessors = FilterUtil.buildDownstreamFilter( - extensionRegistry, hcmHttpFilters, filterConfigs); + httpClient = upstreamDecoration.decorate(DelegatingHttpClient.of()); + rpcClient = upstreamDecoration.rpcDecorate(DelegatingRpcClient.of()); httpPreClient = downstreamPreprocessors.decorate(DelegatingHttpClient.of()); rpcPreClient = downstreamPreprocessors.rpcDecorate(DelegatingRpcClient.of()); } diff --git a/xds/src/main/java/com/linecorp/armeria/xds/RouteStream.java b/xds/src/main/java/com/linecorp/armeria/xds/RouteStream.java index 51cb85b9ecb..37dc8754633 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/RouteStream.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/RouteStream.java @@ -18,17 +18,25 @@ import static com.linecorp.armeria.xds.XdsType.ROUTE; +import java.util.List; +import java.util.Map; + import com.google.common.collect.ImmutableList; +import com.google.protobuf.Any; +import com.linecorp.armeria.client.ClientDecoration; +import com.linecorp.armeria.client.ClientPreprocessors; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.xds.stream.RefCountedStream; import com.linecorp.armeria.xds.stream.SnapshotStream; import com.linecorp.armeria.xds.stream.Subscription; import io.envoyproxy.envoy.config.core.v3.ConfigSource; +import io.envoyproxy.envoy.config.route.v3.RetryPolicy; import io.envoyproxy.envoy.config.route.v3.Route; import io.envoyproxy.envoy.config.route.v3.RouteConfiguration; import io.envoyproxy.envoy.config.route.v3.VirtualHost; +import io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.HttpFilter; final class RouteStream extends RefCountedStream { @@ -167,22 +175,65 @@ private static class RouteEntryStream extends RefCountedStream { @Override protected Subscription onStart(SnapshotWatcher watcher) { final XdsExtensionRegistry extensionRegistry = context.extensionRegistry(); + + // Merge per_filter_config: route-config level < vhost level < route level + final Map routeConfigFilterConfigs = + routeResource.resource().getTypedPerFilterConfigMap(); + final Map vhostFilterConfigs = + vhostResource.resource().getTypedPerFilterConfigMap(); + final Map routeFilterConfigs = + route.getTypedPerFilterConfigMap(); + final Map filterConfigs = FilterUtil.mergeFilterConfigs( + FilterUtil.mergeFilterConfigs(routeConfigFilterConfigs, vhostFilterConfigs), + routeFilterConfigs); + + // Extract HCM downstream filters + final List hcmHttpFilters; + if (listenerResource != null && listenerResource.connectionManager() != null) { + hcmHttpFilters = listenerResource.connectionManager().getHttpFiltersList(); + } else { + hcmHttpFilters = ImmutableList.of(); + } + + // Extract upstream HTTP filters from the Router + final List upstreamFilters; + if (listenerResource != null && listenerResource.router() != null) { + upstreamFilters = listenerResource.router().getUpstreamHttpFiltersList(); + } else { + upstreamFilters = ImmutableList.of(); + } + + // Determine retry policy + final RetryPolicy retryPolicy = route.getRoute().getRetryPolicy(); + final RetryPolicy effectiveRetryPolicy = + retryPolicy == RetryPolicy.getDefaultInstance() ? null : retryPolicy; + + // Build filter streams + final SnapshotStream downstreamStream = + FilterUtil.buildDownstreamFilter(extensionRegistry, context, + hcmHttpFilters, filterConfigs); + final SnapshotStream upstreamStream = + FilterUtil.buildUpstreamFilter(extensionRegistry, context, + upstreamFilters, filterConfigs, + effectiveRetryPolicy); + if (!route.getRoute().hasCluster()) { - return SnapshotStream.just(new RouteEntry(route, null, index, - listenerResource, routeResource, vhostResource, - extensionRegistry)) + return SnapshotStream.combineLatest( + downstreamStream, upstreamStream, + (down, up) -> new RouteEntry( + route, null, index, filterConfigs, down, up)) .subscribe(watcher); } - final SnapshotWatcher mapped = (snapshot, t) -> { - if (snapshot == null) { - watcher.onUpdate(null, t); - return; - } - watcher.onUpdate(new RouteEntry(route, snapshot, index, - listenerResource, routeResource, vhostResource, - extensionRegistry), null); - }; - return context.clusterManager().register(clusterName, context, mapped); + + // Wrap cluster registration as SnapshotStream + final SnapshotStream clusterStream = + w -> context.clusterManager().register(clusterName, context, w); + + return SnapshotStream.combineLatest( + clusterStream, downstreamStream, upstreamStream, + (cluster, down, up) -> new RouteEntry( + route, cluster, index, filterConfigs, down, up)) + .subscribe(watcher); } } } diff --git a/xds/src/main/java/com/linecorp/armeria/xds/SubscriptionContext.java b/xds/src/main/java/com/linecorp/armeria/xds/SubscriptionContext.java index bb912ff1ce6..1fca1c86c59 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/SubscriptionContext.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/SubscriptionContext.java @@ -16,19 +16,29 @@ package com.linecorp.armeria.xds; -import com.linecorp.armeria.common.file.DirectoryWatchService; -import com.linecorp.armeria.common.metric.MeterIdPrefix; - -import io.micrometer.core.instrument.MeterRegistry; -import io.netty.util.concurrent.EventExecutor; - -interface SubscriptionContext { +import static java.util.Objects.requireNonNull; - EventExecutor eventLoop(); - - MeterRegistry meterRegistry(); - - MeterIdPrefix meterIdPrefix(); +import com.linecorp.armeria.common.file.DirectoryWatchService; +import com.linecorp.armeria.xds.filter.FactoryContext; +import com.linecorp.armeria.xds.stream.SnapshotStream; + +import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.SdsSecretConfig; + +interface SubscriptionContext extends FactoryContext { + + @Override + default XdsResourceValidator validator() { + return extensionRegistry().validator(); + } + + @Override + default SnapshotStream genericSecretStream( + SdsSecretConfig sdsSecretConfig) { + requireNonNull(sdsSecretConfig, "sdsSecretConfig"); + return new SecretStream(sdsSecretConfig, null, this) + .switchMapEager(resource -> new GenericSecretStream(this, resource)) + .checkSubscribeOn(eventLoop()); + } void subscribe(ResourceNode node); diff --git a/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/RouterFilterFactory.java b/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/RouterFilterFactory.java index 4e7aa91023c..3a05289c123 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/RouterFilterFactory.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/RouterFilterFactory.java @@ -28,7 +28,7 @@ import com.linecorp.armeria.common.RpcRequest; import com.linecorp.armeria.common.RpcResponse; import com.linecorp.armeria.common.annotation.UnstableApi; -import com.linecorp.armeria.xds.XdsResourceValidator; +import com.linecorp.armeria.xds.filter.FactoryContext; import com.linecorp.armeria.xds.filter.HttpFilterFactory; import com.linecorp.armeria.xds.filter.XdsHttpFilter; @@ -59,12 +59,12 @@ public List typeUrls() { } @Override - public XdsHttpFilter create(HttpFilter filter, Any config, XdsResourceValidator validator) { + public XdsHttpFilter create(HttpFilter filter, Any config, FactoryContext context) { final Router router; if (config == Any.getDefaultInstance()) { router = Router.getDefaultInstance(); } else { - router = validator.unpack(config, Router.class); + router = context.validator().unpack(config, Router.class); } return new RouterXdsHttpFilter(router); } diff --git a/xds/src/main/java/com/linecorp/armeria/xds/filter/CredentialInjectorFilterFactory.java b/xds/src/main/java/com/linecorp/armeria/xds/filter/CredentialInjectorFilterFactory.java new file mode 100644 index 00000000000..e9127671edc --- /dev/null +++ b/xds/src/main/java/com/linecorp/armeria/xds/filter/CredentialInjectorFilterFactory.java @@ -0,0 +1,158 @@ +/* + * Copyright 2025 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.filter; + +import java.util.List; + +import com.google.common.collect.ImmutableList; +import com.google.protobuf.Any; + +import com.linecorp.armeria.client.ClientRequestContext; +import com.linecorp.armeria.client.DecoratingHttpClientFunction; +import com.linecorp.armeria.client.HttpPreprocessor; +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.xds.GenericSecretSnapshot; +import com.linecorp.armeria.xds.stream.SnapshotStream; + +import io.envoyproxy.envoy.extensions.filters.http.credential_injector.v3.CredentialInjector; +import io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.HttpFilter; +import io.envoyproxy.envoy.extensions.http.injected_credentials.generic.v3.Generic; + +/** + * An {@link HttpFilterFactory} for the + * {@code envoy.filters.http.credential_injector} filter with the Generic credential provider. + * + *

This filter injects credentials from an SDS secret into outgoing HTTP requests. + * The credential is reactively updated when the SDS secret rotates. + */ +@UnstableApi +public final class CredentialInjectorFilterFactory implements HttpFilterFactory { + + private static final String NAME = "envoy.filters.http.credential_injector"; + private static final String TYPE_URL = + "type.googleapis.com/envoy.extensions.filters.http.credential_injector.v3.CredentialInjector"; + private static final String GENERIC_TYPE_URL = + "type.googleapis.com/envoy.extensions.http.injected_credentials.generic.v3.Generic"; + private static final List TYPE_URLS = ImmutableList.of(TYPE_URL); + private static final String DEFAULT_HEADER = HttpHeaderNames.AUTHORIZATION.toString(); + + @Override + public String name() { + return NAME; + } + + @Override + public List typeUrls() { + return TYPE_URLS; + } + + @Override + @Nullable + public XdsHttpFilter create(HttpFilter httpFilter, Any config, FactoryContext context) { + throw new UnsupportedOperationException( + "credential_injector requires reactive secret subscription; use createStream()"); + } + + @Override + public SnapshotStream createStream(HttpFilter httpFilter, Any config, + FactoryContext context) { + final CredentialInjector injectorConfig = context.validator().unpack(config, + CredentialInjector.class); + final boolean overwrite = injectorConfig.getOverwrite(); + final boolean allowWithoutCredential = injectorConfig.getAllowRequestWithoutCredential(); + + final Any typedConfig = injectorConfig.getCredential().getTypedConfig(); + if (!GENERIC_TYPE_URL.equals(typedConfig.getTypeUrl())) { + throw new IllegalArgumentException( + "Unsupported credential type: " + typedConfig.getTypeUrl() + + "; only Generic (" + GENERIC_TYPE_URL + ") is supported"); + } + + final Generic generic = context.validator().unpack(typedConfig, Generic.class); + final String header = generic.getHeader().isEmpty() ? DEFAULT_HEADER : generic.getHeader(); + + final SnapshotStream genericSecretStream = + context.genericSecretStream(generic.getCredential()); + + return genericSecretStream.map(snapshot -> { + return new CredentialInjectorXdsHttpFilter(snapshot.credential(), header, overwrite, + allowWithoutCredential); + }); + } + + private static final class CredentialInjectorXdsHttpFilter implements XdsHttpFilter { + + @Nullable + private final String credential; + private final String header; + private final boolean overwrite; + private final boolean allowWithoutCredential; + + CredentialInjectorXdsHttpFilter(@Nullable String credential, String header, + boolean overwrite, boolean allowWithoutCredential) { + this.credential = credential; + this.header = header; + this.overwrite = overwrite; + this.allowWithoutCredential = allowWithoutCredential; + } + + @Override + public HttpPreprocessor httpPreprocessor() { + return (delegate, ctx, req) -> { + if (credential == null) { + if (!allowWithoutCredential) { + return HttpResponse.of(HttpStatus.UNAUTHORIZED); + } + return delegate.execute(ctx, req); + } + if (!overwrite && headerExists(ctx, req)) { + return delegate.execute(ctx, req); + } + ctx.setAdditionalRequestHeader(header, credential); + return delegate.execute(ctx, req); + }; + } + + @Override + public DecoratingHttpClientFunction httpDecorator() { + return (delegate, ctx, req) -> { + if (credential == null) { + if (!allowWithoutCredential) { + return HttpResponse.of(HttpStatus.UNAUTHORIZED); + } + return delegate.execute(ctx, req); + } + if (!overwrite && headerExists(ctx, req)) { + return delegate.execute(ctx, req); + } + ctx.setAdditionalRequestHeader(header, credential); + return delegate.execute(ctx, req); + }; + } + + private boolean headerExists(ClientRequestContext ctx, HttpRequest req) { + return req.headers().contains(header) || + ctx.defaultRequestHeaders().contains(header) || + ctx.additionalRequestHeaders().contains(header); + } + } +} diff --git a/xds/src/main/java/com/linecorp/armeria/xds/filter/FactoryContext.java b/xds/src/main/java/com/linecorp/armeria/xds/filter/FactoryContext.java new file mode 100644 index 00000000000..5e60c00bbb9 --- /dev/null +++ b/xds/src/main/java/com/linecorp/armeria/xds/filter/FactoryContext.java @@ -0,0 +1,65 @@ +/* + * 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.filter; + +import com.linecorp.armeria.common.annotation.UnstableApi; +import com.linecorp.armeria.common.metric.MeterIdPrefix; +import com.linecorp.armeria.xds.GenericSecretSnapshot; +import com.linecorp.armeria.xds.XdsResourceValidator; +import com.linecorp.armeria.xds.stream.SnapshotStream; + +import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.SdsSecretConfig; +import io.micrometer.core.instrument.MeterRegistry; +import io.netty.util.concurrent.EventExecutor; + +/** + * Provides runtime infrastructure to xDS extension factories (HTTP filters, transport sockets, + * network filters, etc.) during creation. + */ +@UnstableApi +public interface FactoryContext { + + /** + * Returns the event loop used for scheduling and executing asynchronous operations. + */ + EventExecutor eventLoop(); + + /** + * Returns the {@link MeterRegistry} for recording metrics. + */ + MeterRegistry meterRegistry(); + + /** + * Returns the {@link MeterIdPrefix} for metric naming. + */ + MeterIdPrefix meterIdPrefix(); + + /** + * Returns the {@link XdsResourceValidator} for validating and unpacking protobuf messages. + */ + XdsResourceValidator validator(); + + /** + * Creates a reactive {@link SnapshotStream} of {@link GenericSecretSnapshot} that fetches + * a generic secret via SDS or bootstrap, resolves its {@link + * io.envoyproxy.envoy.config.core.v3.DataSource DataSource}, and emits snapshots containing + * the resolved credential value. + * + * @param sdsSecretConfig the SDS secret configuration describing which secret to fetch + */ + SnapshotStream genericSecretStream(SdsSecretConfig sdsSecretConfig); +} diff --git a/xds/src/main/java/com/linecorp/armeria/xds/filter/HttpFilterFactory.java b/xds/src/main/java/com/linecorp/armeria/xds/filter/HttpFilterFactory.java index ffc0328d87d..a1ff7b0a2a1 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/filter/HttpFilterFactory.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/filter/HttpFilterFactory.java @@ -21,7 +21,7 @@ import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.annotation.UnstableApi; import com.linecorp.armeria.xds.XdsExtensionFactory; -import com.linecorp.armeria.xds.XdsResourceValidator; +import com.linecorp.armeria.xds.stream.SnapshotStream; import io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager; import io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.HttpFilter; @@ -51,8 +51,32 @@ public interface HttpFilterFactory extends XdsExtensionFactory { * @param httpFilter the filter descriptor from {@link HttpConnectionManager#getHttpFiltersList()} * @param config the raw typed config {@link Any}; may be {@link Any#getDefaultInstance()} * if no config was provided - * @param validator the {@link XdsResourceValidator} for validating and unpacking {@link Any} protos + * @param context the {@link FactoryContext} providing runtime infrastructure such as + * event loop, metrics, and secret subscriptions */ @Nullable - XdsHttpFilter create(HttpFilter httpFilter, Any config, XdsResourceValidator validator); + XdsHttpFilter create(HttpFilter httpFilter, Any config, FactoryContext context); + + /** + * Creates a {@link SnapshotStream} of {@link XdsHttpFilter} for the given filter and its raw typed config. + * + *

The default implementation delegates to {@link #create} and wraps the result in + * {@link SnapshotStream#just}. Override this method for filters that depend on external + * xDS resources and need reactive lifecycle management. + * + * @param httpFilter the filter descriptor from {@link HttpConnectionManager#getHttpFiltersList()} + * @param config the raw typed config {@link Any}; may be {@link Any#getDefaultInstance()} + * if no config was provided + * @param context the {@link FactoryContext} providing runtime infrastructure such as + * event loop, metrics, and secret subscriptions + * @return a stream of filter instances + */ + default SnapshotStream createStream(HttpFilter httpFilter, Any config, + FactoryContext context) { + final XdsHttpFilter filter = create(httpFilter, config, context); + if (filter == null) { + return SnapshotStream.just(XdsHttpFilter.noop()); + } + return SnapshotStream.just(filter); + } } diff --git a/xds/src/main/java/com/linecorp/armeria/xds/filter/NoopXdsHttpFilter.java b/xds/src/main/java/com/linecorp/armeria/xds/filter/NoopXdsHttpFilter.java new file mode 100644 index 00000000000..da205b9dc16 --- /dev/null +++ b/xds/src/main/java/com/linecorp/armeria/xds/filter/NoopXdsHttpFilter.java @@ -0,0 +1,21 @@ +/* + * Copyright 2025 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.filter; + +enum NoopXdsHttpFilter implements XdsHttpFilter { + INSTANCE +} diff --git a/xds/src/main/java/com/linecorp/armeria/xds/filter/XdsHttpFilter.java b/xds/src/main/java/com/linecorp/armeria/xds/filter/XdsHttpFilter.java index 7a648f898a3..a4066ea7168 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/filter/XdsHttpFilter.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/filter/XdsHttpFilter.java @@ -31,6 +31,13 @@ @UnstableApi public interface XdsHttpFilter { + /** + * Returns a no-op filter that passes through all operations unchanged. + */ + static XdsHttpFilter noop() { + return NoopXdsHttpFilter.INSTANCE; + } + /** * Returns the {@link HttpPreprocessor} for downstream filter usage. */ diff --git a/xds/src/main/java/com/linecorp/armeria/xds/stream/RescheduleSubscription.java b/xds/src/main/java/com/linecorp/armeria/xds/stream/RescheduleSubscription.java new file mode 100644 index 00000000000..a67f13ea49f --- /dev/null +++ b/xds/src/main/java/com/linecorp/armeria/xds/stream/RescheduleSubscription.java @@ -0,0 +1,58 @@ +/* + * 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.stream; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.xds.SnapshotWatcher; + +import io.netty.util.concurrent.EventExecutor; + +final class RescheduleSubscription implements SnapshotWatcher, Subscription { + + private final SnapshotWatcher downstream; + private final EventExecutor eventLoop; + @Nullable + private Subscription upstream; + private boolean closed; + + RescheduleSubscription(SnapshotWatcher downstream, EventExecutor eventLoop) { + this.downstream = downstream; + this.eventLoop = eventLoop; + } + + void setUpstream(Subscription upstream) { + this.upstream = upstream; + } + + @Override + public void onUpdate(@Nullable T value, @Nullable Throwable error) { + eventLoop.execute(() -> { + if (!closed) { + downstream.onUpdate(value, error); + } + }); + } + + @Override + public void close() { + assert eventLoop.inEventLoop(); + closed = true; + if (upstream != null) { + upstream.close(); + } + } +} diff --git a/xds/src/main/java/com/linecorp/armeria/xds/stream/SnapshotStream.java b/xds/src/main/java/com/linecorp/armeria/xds/stream/SnapshotStream.java index 7340a9888e7..b81478b4c28 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/stream/SnapshotStream.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/stream/SnapshotStream.java @@ -16,6 +16,7 @@ package com.linecorp.armeria.xds.stream; +import static com.google.common.base.Preconditions.checkState; import static java.util.Objects.requireNonNull; import java.util.List; @@ -29,6 +30,8 @@ import com.linecorp.armeria.common.annotation.UnstableApi; import com.linecorp.armeria.xds.SnapshotWatcher; +import io.netty.util.concurrent.EventExecutor; + /** * A reactive stream that delivers snapshot values to {@link SnapshotWatcher} subscribers. * Subscribers receive the latest value immediately upon subscription (if available) @@ -179,4 +182,43 @@ static SnapshotStream error(Throwable error) { requireNonNull(error, "error"); return new StaticSnapshotStream<>(null, error); } + + /** + * Returns a new stream that asserts {@link #subscribe} and {@link Subscription#close()} + * are called from the given event loop. Throws {@link IllegalStateException} if called + * from a different thread. + * + * @param eventLoop the event loop that subscribe and close must be called from + */ + default SnapshotStream checkSubscribeOn(EventExecutor eventLoop) { + requireNonNull(eventLoop, "eventLoop"); + final SnapshotStream self = this; + return watcher -> { + checkState(eventLoop.inEventLoop(), + "subscribe must be called from the event loop: %s", eventLoop); + final Subscription sub = self.subscribe(watcher); + return () -> { + checkState(eventLoop.inEventLoop(), + "close must be called from the event loop: %s", eventLoop); + sub.close(); + }; + }; + } + + /** + * Returns a new stream that reschedules all {@link SnapshotWatcher#onUpdate} emissions + * to the given event loop. Emissions are always rescheduled (no {@code inEventLoop()} + * shortcut) to guarantee strict FIFO ordering with respect to other event loop tasks. + * + * @param eventLoop the event loop to deliver emissions on + */ + default SnapshotStream rescheduleEventsOn(EventExecutor eventLoop) { + requireNonNull(eventLoop, "eventLoop"); + final SnapshotStream self = this; + return watcher -> { + final RescheduleSubscription sub = new RescheduleSubscription<>(watcher, eventLoop); + sub.setUpstream(self.subscribe(sub)); + return sub; + }; + } } diff --git a/xds/src/main/resources/META-INF/services/com.linecorp.armeria.xds.filter.HttpFilterFactory b/xds/src/main/resources/META-INF/services/com.linecorp.armeria.xds.filter.HttpFilterFactory index 8f657a61e66..c9ebafbc4a2 100644 --- a/xds/src/main/resources/META-INF/services/com.linecorp.armeria.xds.filter.HttpFilterFactory +++ b/xds/src/main/resources/META-INF/services/com.linecorp.armeria.xds.filter.HttpFilterFactory @@ -15,3 +15,4 @@ # com.linecorp.armeria.xds.client.endpoint.RouterFilterFactory +com.linecorp.armeria.xds.filter.CredentialInjectorFilterFactory