diff --git a/xds/src/main/java/io/grpc/xds/CompositeFilter.java b/xds/src/main/java/io/grpc/xds/CompositeFilter.java new file mode 100644 index 00000000000..865fb7eb898 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/CompositeFilter.java @@ -0,0 +1,650 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed 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 + * + * http://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 io.grpc.xds; + +import com.github.udpa.udpa.type.v1.TypedStruct; +import com.github.xds.type.matcher.v3.Matcher; +import com.google.common.base.Preconditions; +import com.google.protobuf.Any; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import io.envoyproxy.envoy.config.core.v3.TypedExtensionConfig; +import io.envoyproxy.envoy.extensions.common.matching.v3.ExtensionWithMatcher; +import io.envoyproxy.envoy.extensions.common.matching.v3.ExtensionWithMatcherPerRoute; +import io.envoyproxy.envoy.extensions.filters.http.composite.v3.Composite; +import io.envoyproxy.envoy.extensions.filters.http.composite.v3.ExecuteFilterAction; +import io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput; +import io.envoyproxy.envoy.type.v3.FractionalPercent; +import io.grpc.ClientInterceptor; +import io.grpc.ClientInterceptors; +import io.grpc.ForwardingClientCallListener.SimpleForwardingClientCallListener; +import io.grpc.ForwardingServerCallListener.SimpleForwardingServerCallListener; +import io.grpc.Grpc; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.Status; +import io.grpc.internal.GrpcUtil; +import io.grpc.xds.internal.UnifiedMatcher; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadLocalRandom; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import javax.net.ssl.SNIHostName; +import javax.net.ssl.SNIServerName; +import javax.net.ssl.SSLSession; +import javax.net.ssl.StandardConstants; + +public final class CompositeFilter implements Filter { + + static final String TYPE_URL_EXTENSION_WITH_MATCHER = + "type.googleapis.com/envoy.extensions.common.matching.v3.ExtensionWithMatcher"; + static final String TYPE_URL_COMPOSITE = + "type.googleapis.com/envoy.extensions.filters.http.composite.v3.Composite"; + static final String TYPE_URL_EXTENSION_WITH_MATCHER_PER_ROUTE = + "type.googleapis.com/envoy.extensions.common.matching.v3.ExtensionWithMatcherPerRoute"; + static final String TYPE_URL_HTTP_REQUEST_HEADER_MATCH_INPUT = + "type.googleapis.com/envoy.type.matcher.v3.HttpRequestHeaderMatchInput"; + static final String TYPE_URL_SOURCE_IP_INPUT = + "type.googleapis.com/envoy.extensions.matching.common_inputs.network.v3.SourceIPInput"; + static final String TYPE_URL_SOURCE_PORT_INPUT = + "type.googleapis.com/envoy.extensions.matching.common_inputs.network.v3.SourcePortInput"; + static final String TYPE_URL_DIRECT_SOURCE_IP_INPUT = + "type.googleapis.com/envoy.extensions.matching.common_inputs.network.v3.DirectSourceIPInput"; + static final String TYPE_URL_SERVER_NAME_INPUT = + "type.googleapis.com/envoy.extensions.matching.common_inputs.network.v3.ServerNameInput"; + + private static final CompositeFilter INSTANCE = new CompositeFilter(); + private static final Logger log = Logger.getLogger(CompositeFilter.class.getName()); + + private CompositeFilter() { + } + + static final class Provider implements Filter.Provider { + private static final ThreadLocal recursionDepth = ThreadLocal.withInitial(() -> 0); + + @Override + public String[] typeUrls() { + return new String[] { + TYPE_URL_EXTENSION_WITH_MATCHER, + TYPE_URL_COMPOSITE, + TYPE_URL_EXTENSION_WITH_MATCHER_PER_ROUTE + }; + } + + @Override + public boolean isClientFilter() { + return true; + } + + @Override + public boolean isServerFilter() { + return true; + } + + @Override + public Filter newInstance(String name) { + return INSTANCE; + } + + @Override + public ConfigOrError parseFilterConfig(Message rawProtoMessage) { + if (!isSupported()) { + return ConfigOrError.fromError("Composite Filter is experimental " + + "and disabled by default."); + } + if (!(rawProtoMessage instanceof Any)) { + return ConfigOrError.fromError("Invalid message type: " + + rawProtoMessage.getClass().getName()); + } + int currentDepth = recursionDepth.get(); + if (currentDepth > 8) { + return ConfigOrError.fromError("Maximum recursion depth of 8 exceeded"); + } + recursionDepth.set(currentDepth + 1); + try { + Any any = (Any) rawProtoMessage; + if (any.is(ExtensionWithMatcher.class)) { + ExtensionWithMatcher proto = any.unpack(ExtensionWithMatcher.class); + return parseMatcherConfig(proto.getXdsMatcher()); + } else if (any.is(Composite.class)) { + return ConfigOrError.fromConfig(new CompositeFilterConfig(null)); + } + } catch (InvalidProtocolBufferException e) { + return ConfigOrError.fromError("Invalid proto: " + e); + } finally { + recursionDepth.set(currentDepth); + } + return ConfigOrError.fromError("Unsupported message type in parseFilterConfig"); + } + + @Override + public ConfigOrError parseFilterConfigOverride(Message rawProtoMessage) { + if (!isSupported()) { + return ConfigOrError.fromError("Composite Filter is experimental and disabled" + + " by default."); + } + if (!(rawProtoMessage instanceof Any)) { + return ConfigOrError.fromError("Invalid message type: " + + rawProtoMessage.getClass().getName()); + } + int currentDepth = recursionDepth.get(); + if (currentDepth > 8) { + return ConfigOrError.fromError("Maximum recursion depth of 8 exceeded"); + } + recursionDepth.set(currentDepth + 1); + try { + Any any = (Any) rawProtoMessage; + if (any.is(ExtensionWithMatcherPerRoute.class)) { + ExtensionWithMatcherPerRoute proto = any.unpack(ExtensionWithMatcherPerRoute.class); + return parseMatcherConfig(proto.getXdsMatcher()); + } + } catch (InvalidProtocolBufferException e) { + return ConfigOrError.fromError("Invalid proto: " + e); + } finally { + recursionDepth.set(currentDepth); + } + return ConfigOrError.fromError("Unsupported message type in " + + "parseFilterConfigOverride"); + } + + private ConfigOrError parseMatcherConfig( + @Nullable Matcher matcherProto) { + if (matcherProto == null) { + return ConfigOrError.fromConfig(new CompositeFilterConfig(null)); + } + + try { + UnifiedMatcher matcher = UnifiedMatcher.create(matcherProto, + Provider::createFilterDelegate); + return ConfigOrError.fromConfig(new CompositeFilterConfig(matcher)); + } catch (Exception e) { + return ConfigOrError.fromError("Failed to create matcher: " + e.getMessage()); + } + } + + private boolean isSupported() { + return GrpcUtil.getFlag("GRPC_EXPERIMENTAL_XDS_COMPOSITE_FILTER", false); + } + + private static FilterDelegate createFilterDelegate( + com.github.xds.core.v3.TypedExtensionConfig config) { + try { + Any actionAny = config.getTypedConfig(); + if (actionAny.is(ExecuteFilterAction.class)) { + ExecuteFilterAction executeAction = actionAny.unpack(ExecuteFilterAction.class); + FractionalPercent samplePercent = executeAction.hasSamplePercent() + ? executeAction.getSamplePercent().getDefaultValue() + : null; + List childConfigs = new ArrayList<>(); + + if (executeAction.hasFilterChain()) { + childConfigs.addAll(executeAction.getFilterChain().getTypedConfigList()); + } else if (executeAction.hasTypedConfig()) { + childConfigs.add(executeAction.getTypedConfig()); + } + + if (childConfigs.isEmpty()) { + return null; + } + + List delegates = new ArrayList<>(); + for (TypedExtensionConfig childFilterConfig : childConfigs) { + String typeUrl = childFilterConfig.getTypedConfig().getTypeUrl(); + Message rawConfig = childFilterConfig.getTypedConfig(); + + try { + if (typeUrl.equals("type.googleapis.com/udpa.type.v1.TypedStruct")) { + TypedStruct typedStruct = childFilterConfig + .getTypedConfig() + .unpack(TypedStruct.class); + typeUrl = typedStruct.getTypeUrl(); + rawConfig = typedStruct.getValue(); + } else if (typeUrl.equals("type.googleapis.com/xds.type.v3.TypedStruct")) { + com.github.xds.type.v3.TypedStruct typedStruct = childFilterConfig + .getTypedConfig() + .unpack(com.github.xds.type.v3.TypedStruct.class); + typeUrl = typedStruct.getTypeUrl(); + rawConfig = typedStruct.getValue(); + } + } catch (InvalidProtocolBufferException e) { + throw new IllegalArgumentException("Failed to unpack TypedStruct", e); + } + + Filter.Provider provider = FilterRegistry.getDefaultRegistry().get(typeUrl); + if (provider == null) { + throw new IllegalArgumentException("Action filter not found: " + typeUrl); + } + ConfigOrError parsed = provider + .parseFilterConfig(rawConfig); + if (parsed.errorDetail != null) { + throw new IllegalArgumentException( + "Failed to parse child filter: " + parsed.errorDetail); + } + delegates.add(new DelegateEntry(provider, parsed.config, childFilterConfig.getName())); + } + return new FilterDelegate(delegates, samplePercent); + } else if (actionAny.getTypeUrl().equals( + "type.googleapis.com/envoy.extensions.filters.common.matcher.action.v3.SkipFilter")) { + return new FilterDelegate(Collections.emptyList(), null); + } + } catch (InvalidProtocolBufferException e) { + throw new RuntimeException(e); + } + return null; + } + } + + static final class CompositeFilterConfig implements FilterConfig { + @Nullable + final UnifiedMatcher matcher; + + CompositeFilterConfig(@Nullable UnifiedMatcher matcher) { + this.matcher = matcher; + } + + @Override + public String typeUrl() { + return TYPE_URL_COMPOSITE; + } + } + + static final class FilterDelegate { + final List delegates; + private final double threshold; + + FilterDelegate(List delegates, @Nullable FractionalPercent samplePercent) { + this.delegates = Collections.unmodifiableList(delegates); + this.threshold = calculateThreshold(samplePercent); + } + + private static double calculateThreshold(@Nullable FractionalPercent samplePercent) { + if (samplePercent == null) { + return 1.0; + } + double numerator = samplePercent.getNumerator(); + double denominator; + switch (samplePercent.getDenominator()) { + case HUNDRED: + denominator = 100.0; + break; + case TEN_THOUSAND: + denominator = 10000.0; + break; + case MILLION: + denominator = 1000000.0; + break; + default: + denominator = 100.0; + } + return numerator / denominator; + } + + boolean shouldExecute() { + if (threshold >= 1.0) { + return true; + } + if (threshold <= 0.0) { + return false; + } + return ThreadLocalRandom.current().nextDouble() < threshold; + } + } + + static final class DelegateEntry { + final Filter.Provider provider; + final FilterConfig config; + final String name; + + DelegateEntry(Filter.Provider provider, FilterConfig config, String name) { + this.provider = provider; + this.config = config; + this.name = name; + } + } + + @Override + public ClientInterceptor buildClientInterceptor(FilterConfig config, FilterConfig overrideConfig, + ScheduledExecutorService scheduler) { + Preconditions.checkNotNull(config, "config"); + UnifiedMatcher matcher = getMatcher(config, overrideConfig); + if (matcher == null) { + return null; + } + + return new ClientInterceptor() { + @Override + public io.grpc.ClientCall interceptCall( + MethodDescriptor method, io.grpc.CallOptions callOptions, + io.grpc.Channel next) { + return new CompositeClientCall<>(method, callOptions, next, matcher, scheduler); + } + }; + } + + @Override + public ServerInterceptor buildServerInterceptor( + FilterConfig config, FilterConfig overrideConfig) { + UnifiedMatcher matcher = getMatcher(config, overrideConfig); + if (matcher == null) { + return null; + } + + return new ServerInterceptor() { + @Override + public io.grpc.ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + + // Populate MatchingData with headers and server call attributes + UnifiedMatcher.MatchingData data = new MatchingDataImpl(headers, null, + call.getAttributes()); + + List delegates = matcher.match(data); + if (delegates != null && !delegates.isEmpty()) { + List interceptors = new ArrayList<>(); + final List filters = new ArrayList<>(); + try { + for (FilterDelegate delegate : delegates) { + if (!delegate.shouldExecute()) { + continue; + } + for (DelegateEntry entry : delegate.delegates) { + Filter filter = entry.provider.newInstance(entry.name); + filters.add(filter); + ServerInterceptor interceptor = filter.buildServerInterceptor(entry.config, null); + if (interceptor != null) { + interceptors.add(interceptor); + } + } + } + } catch (Throwable t) { + for (Filter f : filters) { + f.close(); + } + throw t; + } + + if (!interceptors.isEmpty()) { + ServerCallHandler wrapped = next; + for (int i = interceptors.size() - 1; i >= 0; i--) { + final ServerInterceptor interceptor = interceptors.get(i); + final ServerCallHandler current = wrapped; + wrapped = new ServerCallHandler() { + @Override + public ServerCall.Listener startCall( + ServerCall call, Metadata headers) { + return interceptor.interceptCall(call, headers, current); + } + }; + } + ServerCall.Listener listener = wrapped.startCall(call, headers); + // Wrap listener to close filters + return new SimpleForwardingServerCallListener(listener) { + @Override + public void onCancel() { + try { + super.onCancel(); + } finally { + closeFilters(); + } + } + + @Override + public void onComplete() { + try { + super.onComplete(); + } finally { + closeFilters(); + } + } + + private void closeFilters() { + for (Filter f : filters) { + f.close(); + } + } + }; + } else { + for (Filter f : filters) { + f.close(); + } + } + } + return next.startCall(call, headers); + } + }; + } + + private UnifiedMatcher getMatcher( + FilterConfig config, FilterConfig overrideConfig) { + CompositeFilterConfig effective = (CompositeFilterConfig) config; + if (overrideConfig != null) { + CompositeFilterConfig override = (CompositeFilterConfig) overrideConfig; + if (override.matcher != null) { + return override.matcher; + } + } + return effective.matcher; + } + + private static class MatchingDataImpl implements UnifiedMatcher.MatchingData { + private final Metadata headers; + private final io.grpc.CallOptions callOptions; + private final io.grpc.Attributes attributes; + + MatchingDataImpl(Metadata headers, @Nullable io.grpc.CallOptions callOptions, + io.grpc.Attributes attributes) { + this.headers = headers; + this.callOptions = callOptions; + this.attributes = attributes; + } + + MatchingDataImpl(Metadata headers, @Nullable io.grpc.CallOptions callOptions) { + this(headers, callOptions, io.grpc.Attributes.EMPTY); + } + + @Override + public String getRelayedInput(com.github.xds.core.v3.TypedExtensionConfig inputConfig) { + String typeUrl = inputConfig.getTypedConfig().getTypeUrl(); + if (TYPE_URL_HTTP_REQUEST_HEADER_MATCH_INPUT.equals(typeUrl)) { + try { + HttpRequestHeaderMatchInput input = inputConfig.getTypedConfig() + .unpack(HttpRequestHeaderMatchInput.class); + String headerName = input.getHeaderName(); + return headers.get(Metadata.Key.of(headerName, Metadata.ASCII_STRING_MARSHALLER)); + } catch (InvalidProtocolBufferException e) { + log.log(Level.WARNING, "Unable to get headers from the request for matching", e); + } + } else if (TYPE_URL_SOURCE_IP_INPUT.equals(typeUrl)) { + SocketAddress addr = attributes.get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR); + if (addr instanceof InetSocketAddress) { + return ((InetSocketAddress) addr).getAddress().getHostAddress(); + } + } else if (TYPE_URL_SOURCE_PORT_INPUT.equals(typeUrl)) { + SocketAddress addr = attributes.get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR); + if (addr instanceof InetSocketAddress) { + return String.valueOf(((InetSocketAddress) addr).getPort()); + } + } else if (TYPE_URL_DIRECT_SOURCE_IP_INPUT.equals(typeUrl)) { + // For standard gRPC, direct source IP is usually the same as remote addr, + // unless there is some proxying info that gRPC doesn't standardly expose as + // separate attribute. + // We fallback to remote addr. + SocketAddress addr = attributes.get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR); + if (addr instanceof InetSocketAddress) { + return ((InetSocketAddress) addr).getAddress().getHostAddress(); + } + } else if (TYPE_URL_SERVER_NAME_INPUT.equals(typeUrl)) { + if (callOptions != null && callOptions.getAuthority() != null) { + return callOptions.getAuthority(); + } + SocketAddress remoteAddr = attributes.get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR); + if (remoteAddr instanceof InetSocketAddress) { + // Fallback to simpler check if needed, but SNI is usually in SSLSession + } + SSLSession session = attributes.get(Grpc.TRANSPORT_ATTR_SSL_SESSION); + if (session instanceof javax.net.ssl.ExtendedSSLSession) { + javax.net.ssl.ExtendedSSLSession extSession = (javax.net.ssl.ExtendedSSLSession) session; + List names = extSession.getRequestedServerNames(); + for (SNIServerName name : names) { + if (name.getType() == StandardConstants.SNI_HOST_NAME && name instanceof SNIHostName) { + return ((SNIHostName) name).getAsciiName(); + } + } + } + } + return null; + } + } + + private static class CompositeClientCall extends io.grpc.ClientCall { + private final MethodDescriptor method; + private final io.grpc.CallOptions callOptions; + private final io.grpc.Channel next; + private final UnifiedMatcher matcher; + private final ScheduledExecutorService scheduler; + private io.grpc.ClientCall delegate; + private boolean started; + private boolean cancelled; + private String cancelMessage; + private Throwable cancelCause; + + CompositeClientCall(MethodDescriptor method, io.grpc.CallOptions callOptions, + io.grpc.Channel next, UnifiedMatcher matcher, + ScheduledExecutorService scheduler) { + this.method = method; + this.callOptions = callOptions; + this.next = next; + this.matcher = matcher; + this.scheduler = scheduler; + } + + @Override + public void start(Listener responseListener, Metadata headers) { + Preconditions.checkState(!started, "Already started"); + started = true; + + UnifiedMatcher.MatchingData data = new MatchingDataImpl(headers, callOptions); + List filterDelegates = matcher.match(data); + + if (filterDelegates != null && !filterDelegates.isEmpty()) { + List interceptors = new ArrayList<>(); + List filters = new ArrayList<>(); + try { + for (FilterDelegate filterDelegate : filterDelegates) { + if (!filterDelegate.shouldExecute()) { + continue; + } + for (DelegateEntry entry : filterDelegate.delegates) { + Filter filter = entry.provider.newInstance(entry.name); + filters.add(filter); + ClientInterceptor interceptor = filter.buildClientInterceptor(entry.config, null, + scheduler); + if (interceptor != null) { + interceptors.add(interceptor); + } + } + } + } catch (Throwable t) { + for (Filter f : filters) { + f.close(); + } + throw t; + } + + if (!interceptors.isEmpty()) { + delegate = ClientInterceptors.intercept(next, interceptors).newCall(method, callOptions); + // Wrap responseListener to close filters + responseListener = new SimpleForwardingClientCallListener(responseListener) { + @Override + public void onClose(Status status, Metadata trailers) { + try { + super.onClose(status, trailers); + } finally { + for (Filter f : filters) { + f.close(); + } + } + } + }; + } else { + for (Filter f : filters) { + f.close(); + } + } + } + + if (delegate == null) { + delegate = next.newCall(method, callOptions); + } + + delegate.start(responseListener, headers); + + if (cancelled) { + delegate.cancel(cancelMessage, cancelCause); + } + } + + @Override + public void request(int numMessages) { + checkDelegate(); + delegate.request(numMessages); + } + + @Override + public void cancel(@Nullable String message, @Nullable Throwable cause) { + if (delegate != null) { + delegate.cancel(message, cause); + } else { + cancelled = true; + cancelMessage = message; + cancelCause = cause; + } + } + + @Override + public void halfClose() { + checkDelegate(); + delegate.halfClose(); + } + + @Override + public void sendMessage(ReqT message) { + checkDelegate(); + delegate.sendMessage(message); + } + + @Override + public void setMessageCompression(boolean enabled) { + checkDelegate(); + delegate.setMessageCompression(enabled); + } + + private void checkDelegate() { + Preconditions.checkState(delegate != null, + "Not started"); + } + } +} diff --git a/xds/src/main/java/io/grpc/xds/FilterRegistry.java b/xds/src/main/java/io/grpc/xds/FilterRegistry.java index da3a59fe8c1..8eba8b89df8 100644 --- a/xds/src/main/java/io/grpc/xds/FilterRegistry.java +++ b/xds/src/main/java/io/grpc/xds/FilterRegistry.java @@ -35,10 +35,10 @@ private FilterRegistry() {} static synchronized FilterRegistry getDefaultRegistry() { if (instance == null) { instance = newRegistry().register( - new FaultFilter.Provider(), - new RouterFilter.Provider(), - new RbacFilter.Provider(), - new GcpAuthenticationFilter.Provider()); + new FaultFilter.Provider(), + new RouterFilter.Provider(), + new RbacFilter.Provider(), + new GcpAuthenticationFilter.Provider()); } return instance; } @@ -58,6 +58,13 @@ FilterRegistry register(Filter.Provider... filters) { return this; } + @VisibleForTesting + void deregister(Filter.Provider provider) { + for (String typeUrl : provider.typeUrls()) { + supportedFilters.remove(typeUrl); + } + } + @Nullable Filter.Provider get(String typeUrl) { return supportedFilters.get(typeUrl); diff --git a/xds/src/main/java/io/grpc/xds/VirtualHost.java b/xds/src/main/java/io/grpc/xds/VirtualHost.java index 5cc979984c6..57a623ba805 100644 --- a/xds/src/main/java/io/grpc/xds/VirtualHost.java +++ b/xds/src/main/java/io/grpc/xds/VirtualHost.java @@ -65,21 +65,38 @@ abstract static class Route { abstract ImmutableMap filterConfigOverrides(); + @Nullable + abstract ImmutableMap filterMetadata(); + static Route forAction(RouteMatch routeMatch, RouteAction routeAction, Map filterConfigOverrides) { - return create(routeMatch, routeAction, filterConfigOverrides); + return create(routeMatch, routeAction, filterConfigOverrides, null); + } + + static Route forAction(RouteMatch routeMatch, RouteAction routeAction, + Map filterConfigOverrides, + @Nullable Map filterMetadata) { + return create(routeMatch, routeAction, filterConfigOverrides, filterMetadata); } static Route forNonForwardingAction(RouteMatch routeMatch, Map filterConfigOverrides) { - return create(routeMatch, null, filterConfigOverrides); + return create(routeMatch, null, filterConfigOverrides, null); + } + + static Route forNonForwardingAction(RouteMatch routeMatch, + Map filterConfigOverrides, + @Nullable Map filterMetadata) { + return create(routeMatch, null, filterConfigOverrides, filterMetadata); } private static Route create( RouteMatch routeMatch, @Nullable RouteAction routeAction, - Map filterConfigOverrides) { + Map filterConfigOverrides, + @Nullable Map filterMetadata) { return new AutoValue_VirtualHost_Route( - routeMatch, routeAction, ImmutableMap.copyOf(filterConfigOverrides)); + routeMatch, routeAction, ImmutableMap.copyOf(filterConfigOverrides), + filterMetadata == null ? null : ImmutableMap.copyOf(filterMetadata)); } @AutoValue diff --git a/xds/src/main/java/io/grpc/xds/XdsAttributes.java b/xds/src/main/java/io/grpc/xds/XdsAttributes.java index d3fe8d4619c..3c9436a417a 100644 --- a/xds/src/main/java/io/grpc/xds/XdsAttributes.java +++ b/xds/src/main/java/io/grpc/xds/XdsAttributes.java @@ -16,6 +16,7 @@ package io.grpc.xds; +import com.google.protobuf.Struct; import io.grpc.Attributes; import io.grpc.EquivalentAddressGroup; import io.grpc.Grpc; @@ -24,6 +25,7 @@ import io.grpc.xds.XdsNameResolverProvider.CallCounterProvider; import io.grpc.xds.client.Locality; import io.grpc.xds.client.XdsClient; +import java.util.Map; /** * Attributes used for xDS implementation. @@ -100,5 +102,18 @@ final class XdsAttributes { static final Attributes.Key ATTR_DRAIN_GRACE_NANOS = Attributes.Key.create("io.grpc.xds.XdsAttributes.drainGraceTime"); + /** + * Attribute key for xDS route metadata used in filter matching. + */ + @NameResolver.ResolutionResultAttr + public static final Attributes.Key> ATTR_FILTER_METADATA = Attributes.Key + .create("io.grpc.xds.XdsAttributes.filterMetadata"); + + /** + * CallOptions key for xDS route metadata used in filter matching. + */ + public static final io.grpc.CallOptions.Key> CALL_OPTIONS_FILTER_METADATA = + io.grpc.CallOptions.Key.create("io.grpc.xds.XdsAttributes.filterMetadata"); + private XdsAttributes() {} } diff --git a/xds/src/main/java/io/grpc/xds/XdsListenerResource.java b/xds/src/main/java/io/grpc/xds/XdsListenerResource.java index 041b659b4c3..1bba71f1ff6 100644 --- a/xds/src/main/java/io/grpc/xds/XdsListenerResource.java +++ b/xds/src/main/java/io/grpc/xds/XdsListenerResource.java @@ -230,7 +230,7 @@ static FilterChain parseFilterChain( // FilterChain contains L4 filters, so we ensure it contains only HCM. if (proto.getFiltersCount() != 1) { throw new ResourceInvalidException("FilterChain " + filterChainName - + " should contain exact one HttpConnectionManager filter"); + + " should contain exactly one HttpConnectionManager filter"); } io.envoyproxy.envoy.config.listener.v3.Filter l4Filter = proto.getFiltersList().get(0); if (!l4Filter.hasTypedConfig()) { diff --git a/xds/src/main/java/io/grpc/xds/XdsNameResolver.java b/xds/src/main/java/io/grpc/xds/XdsNameResolver.java index 69b0b824433..cecfa39a3e5 100644 --- a/xds/src/main/java/io/grpc/xds/XdsNameResolver.java +++ b/xds/src/main/java/io/grpc/xds/XdsNameResolver.java @@ -722,6 +722,7 @@ public void onUpdate(StatusOr updateOrStatus) { } VirtualHost virtualHost = update.getVirtualHost(); + // filters and there configurations ImmutableList filterConfigs = httpConnectionManager.httpFilterConfigs(); long streamDurationNano = httpConnectionManager.httpMaxStreamDurationNano(); diff --git a/xds/src/main/java/io/grpc/xds/XdsRouteConfigureResource.java b/xds/src/main/java/io/grpc/xds/XdsRouteConfigureResource.java index 24ec0659b42..9a90567a536 100644 --- a/xds/src/main/java/io/grpc/xds/XdsRouteConfigureResource.java +++ b/xds/src/main/java/io/grpc/xds/XdsRouteConfigureResource.java @@ -29,6 +29,7 @@ import com.google.protobuf.Duration; import com.google.protobuf.InvalidProtocolBufferException; import com.google.protobuf.Message; +import com.google.protobuf.Struct; import com.google.protobuf.util.Durations; import com.google.re2j.Pattern; import com.google.re2j.PatternSyntaxException; @@ -289,6 +290,11 @@ static StructOrError parseRoute( } Map overrideConfigs = overrideConfigsOrError.getStruct(); + Map filterMetadata = null; + if (proto.hasMetadata()) { + filterMetadata = proto.getMetadata().getFilterMetadataMap(); + } + switch (proto.getActionCase()) { case ROUTE: StructOrError routeAction = @@ -303,10 +309,11 @@ static StructOrError parseRoute( + routeAction.getErrorDetail()); } return StructOrError.fromStruct( - Route.forAction(routeMatch.getStruct(), routeAction.getStruct(), overrideConfigs)); + Route.forAction(routeMatch.getStruct(), routeAction.getStruct(), overrideConfigs, + filterMetadata)); case NON_FORWARDING_ACTION: return StructOrError.fromStruct( - Route.forNonForwardingAction(routeMatch.getStruct(), overrideConfigs)); + Route.forNonForwardingAction(routeMatch.getStruct(), overrideConfigs, filterMetadata)); case REDIRECT: case DIRECT_RESPONSE: case FILTER_ACTION: diff --git a/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java b/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java index 91b77b05d01..aa17063eb53 100644 --- a/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java +++ b/xds/src/main/java/io/grpc/xds/internal/MatcherParser.java @@ -91,6 +91,8 @@ public static Matchers.StringMatcher parseStringMatcher( Pattern.compile(proto.getSafeRegex().getRegex())); case CONTAINS: return Matchers.StringMatcher.forContains(proto.getContains()); + case CUSTOM: + throw new IllegalArgumentException("custom string matcher is not supported"); case MATCHPATTERN_NOT_SET: default: throw new IllegalArgumentException( @@ -98,6 +100,30 @@ public static Matchers.StringMatcher parseStringMatcher( } } + /** Translate StringMatcher xDS proto to internal StringMatcher. */ + public static Matchers.StringMatcher parseStringMatcher( + com.github.xds.type.matcher.v3.StringMatcher proto) { + switch (proto.getMatchPatternCase()) { + case EXACT: + return Matchers.StringMatcher.forExact(proto.getExact(), proto.getIgnoreCase()); + case PREFIX: + return Matchers.StringMatcher.forPrefix(proto.getPrefix(), proto.getIgnoreCase()); + case SUFFIX: + return Matchers.StringMatcher.forSuffix(proto.getSuffix(), proto.getIgnoreCase()); + case SAFE_REGEX: + return Matchers.StringMatcher.forSafeRegEx( + Pattern.compile(proto.getSafeRegex().getRegex())); + case CONTAINS: + return Matchers.StringMatcher.forContains(proto.getContains()); + case CUSTOM: + throw new IllegalArgumentException("custom string matcher is not supported"); + case MATCHPATTERN_NOT_SET: + default: + throw new IllegalArgumentException( + "Unknown StringMatcher match pattern: " + proto.getMatchPatternCase()); + } + } + /** Translates envoy proto FractionalPercent to internal FractionMatcher. */ public static Matchers.FractionMatcher parseFractionMatcher( io.envoyproxy.envoy.type.v3.FractionalPercent proto) { diff --git a/xds/src/main/java/io/grpc/xds/internal/UnifiedMatcher.java b/xds/src/main/java/io/grpc/xds/internal/UnifiedMatcher.java new file mode 100644 index 00000000000..f799da0d5d3 --- /dev/null +++ b/xds/src/main/java/io/grpc/xds/internal/UnifiedMatcher.java @@ -0,0 +1,350 @@ +/* + * Copyright 2019 The gRPC Authors + * + * Licensed 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 + * + * http://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 io.grpc.xds.internal; + +import com.github.xds.type.matcher.v3.Matcher; +import com.github.xds.type.matcher.v3.Matcher.MatcherList.Predicate; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; + +/** + * Implementation of the xDS Unified Matcher API. + * + * @param The type of the action result. + */ +public abstract class UnifiedMatcher { + + /** + * Represents the inputs required for matching. + */ + public interface MatchingData { + /** + * Retrieves the value for the given input configuration. + * The implementation is responsible for interpreting the input config (e.g., + * extracting header name). + */ + @Nullable + String getRelayedInput(com.github.xds.core.v3.TypedExtensionConfig inputConfig); + } + + /** + * Action parser interface. + */ + public interface ActionParser { + T parse(com.github.xds.core.v3.TypedExtensionConfig config); + } + + /** + * Evaluates the matcher against the provided data. + * Returns the action configs if matched, null otherwise. + */ + @Nullable + public abstract List match(MatchingData data); + + public static UnifiedMatcher create(Matcher proto, ActionParser parser) { + return createRecursive(proto, parser, 0); + } + + private static UnifiedMatcher createRecursive(Matcher proto, ActionParser parser, + int depth) { + if (depth > 16) { + throw new IllegalArgumentException("Maximum recursion depth of 16 exceeded"); + } + if (proto.hasMatcherList()) { + return new MatcherList<>(proto.getMatcherList(), parser, depth); + } else if (proto.hasMatcherTree()) { + return new MatcherTree<>(proto.getMatcherTree(), parser, depth); + } + return new OnNoMatch<>(proto.getOnNoMatch(), parser, depth); + } + + private static class OnNoMatch extends UnifiedMatcher { + @Nullable + private final UnifiedMatcher delegate; + + OnNoMatch(Matcher.OnMatch proto, ActionParser parser, int depth) { + if (proto.hasMatcher()) { + this.delegate = createRecursive(proto.getMatcher(), parser, depth + 1); + } else if (proto.hasAction()) { + this.delegate = new ActionMatcher<>(proto.getAction(), parser); + } else { + this.delegate = null; + } + } + + @Override + public List match(MatchingData data) { + return delegate != null ? delegate.match(data) : null; + } + } + + private static class ActionMatcher extends UnifiedMatcher { + private final T action; + + ActionMatcher(com.github.xds.core.v3.TypedExtensionConfig proto, ActionParser parser) { + this.action = parser.parse(proto); + } + + @Override + public List match(MatchingData data) { + return ImmutableList.of(action); + } + } + + private static class MatcherList extends UnifiedMatcher { + private final List> fieldMatchers; + + MatcherList(Matcher.MatcherList proto, ActionParser parser, int depth) { + List> matchers = new ArrayList<>(); + for (Matcher.MatcherList.FieldMatcher fieldMatcher : proto.getMatchersList()) { + matchers.add(new FieldMatcher<>(fieldMatcher, parser, depth)); + } + this.fieldMatchers = ImmutableList.copyOf(matchers); + } + + @Override + public List match(MatchingData data) { + List results = new ArrayList<>(); + for (FieldMatcher matcher : fieldMatchers) { + List result = matcher.match(data); + if (result != null && !result.isEmpty()) { + results.addAll(result); + if (!matcher.keepMatching) { + break; + } + } + } + return results.isEmpty() ? null : ImmutableList.copyOf(results); + } + } + + private static class FieldMatcher { + private final UnifiedPredicate predicate; + private final UnifiedMatcher onMatch; + private final boolean keepMatching; + + FieldMatcher(Matcher.MatcherList.FieldMatcher proto, ActionParser parser, int depth) { + this.predicate = UnifiedPredicate.create(proto.getPredicate()); + Matcher.OnMatch onMatchProto = proto.getOnMatch(); + this.keepMatching = onMatchProto.getKeepMatching(); + if (onMatchProto.hasAction()) { + this.onMatch = new ActionMatcher<>(onMatchProto.getAction(), parser); + } else if (onMatchProto.hasMatcher()) { + this.onMatch = createRecursive(onMatchProto.getMatcher(), parser, depth + 1); + } else { + this.onMatch = new AlwaysFalseMatcher<>(); + } + } + + @Nullable + List match(MatchingData data) { + if (predicate.matches(data)) { + return onMatch.match(data); + } + return null; + } + } + + private static class MatcherTree extends UnifiedMatcher { + private final MatcherInput input; + private final Map> exactMap; + private final Map> prefixMap; + + MatcherTree(Matcher.MatcherTree proto, ActionParser parser, int depth) { + this.input = new MatcherInput(proto.getInput()); + if (proto.hasCustomMatch()) { + throw new IllegalArgumentException("custom_match is not supported in MatcherTree"); + } + this.exactMap = parseMap(proto.getExactMatchMap().getMapMap(), parser, depth); + this.prefixMap = parseMap(proto.getPrefixMatchMap().getMapMap(), parser, depth); + } + + private Map> parseMap( + Map protoMap, ActionParser parser, int depth) { + ImmutableMap.Builder> builder = ImmutableMap.builder(); + for (Map.Entry entry : protoMap.entrySet()) { + if (entry.getValue().hasAction()) { + builder.put(entry.getKey(), new ActionMatcher<>(entry.getValue().getAction(), parser)); + } else if (entry.getValue().hasMatcher()) { + builder.put(entry.getKey(), + createRecursive(entry.getValue().getMatcher(), parser, depth + 1)); + } + } + return builder.build(); + } + + @Override + public List match(MatchingData data) { + String value = input.get(data); + if (value == null) { + return null; + } + // Exact match + UnifiedMatcher exact = exactMap.get(value); + if (exact != null) { + return exact.match(data); + } + // Prefix match + String bestPrefix = null; + UnifiedMatcher bestMatch = null; + for (Map.Entry> entry : prefixMap.entrySet()) { + if (value.startsWith(entry.getKey())) { + if (bestPrefix == null || entry.getKey().length() > bestPrefix.length()) { + bestPrefix = entry.getKey(); + bestMatch = entry.getValue(); + } + } + } + if (bestMatch != null) { + return bestMatch.match(data); + } + return null; + } + } + + private static class MatcherInput { + private final com.github.xds.core.v3.TypedExtensionConfig config; + + MatcherInput(com.github.xds.core.v3.TypedExtensionConfig proto) { + this.config = proto; + } + + String get(MatchingData data) { + return data.getRelayedInput(config); + } + } + + private static class AlwaysFalseMatcher extends UnifiedMatcher { + @Override + public List match(MatchingData data) { + return null; + } + } + + private abstract static class UnifiedPredicate { + abstract boolean matches(MatchingData data); + + static UnifiedPredicate create(Predicate proto) { + if (proto.hasSinglePredicate()) { + return new SinglePredicate(proto.getSinglePredicate()); + } else if (proto.hasOrMatcher()) { + return new OrPredicate(proto.getOrMatcher()); + } else if (proto.hasAndMatcher()) { + return new AndPredicate(proto.getAndMatcher()); + } else if (proto.hasNotMatcher()) { + return new NotPredicate(proto.getNotMatcher()); + } + return new AlwaysFalsePredicate(); + } + } + + private static class AlwaysFalsePredicate extends UnifiedPredicate { + @Override + boolean matches(MatchingData data) { + return false; + } + } + + private static class SinglePredicate extends UnifiedPredicate { + private final MatcherInput input; + private final Matchers.StringMatcher stringMatcher; + + SinglePredicate(Predicate.SinglePredicate proto) { + this.input = new MatcherInput(proto.getInput()); + if (proto.hasCustomMatch()) { + throw new IllegalArgumentException("custom_match is not supported in SinglePredicate"); + } + if (proto.hasValueMatch()) { + this.stringMatcher = MatcherParser.parseStringMatcher(proto.getValueMatch()); + } else { + this.stringMatcher = null; + } + } + + @Override + boolean matches(MatchingData data) { + if (stringMatcher != null) { + String value = input.get(data); + if (value != null) { + return stringMatcher.matches(value); + } + } + return false; + } + } + + private static class OrPredicate extends UnifiedPredicate { + private final List predicates; + + OrPredicate(Predicate.PredicateList proto) { + List list = new ArrayList<>(); + for (Predicate p : proto.getPredicateList()) { + list.add(UnifiedPredicate.create(p)); + } + this.predicates = ImmutableList.copyOf(list); + } + + @Override + boolean matches(MatchingData data) { + for (UnifiedPredicate p : predicates) { + if (p.matches(data)) { + return true; + } + } + return false; + } + } + + private static class AndPredicate extends UnifiedPredicate { + private final List predicates; + + AndPredicate(Predicate.PredicateList proto) { + List list = new ArrayList<>(); + for (Predicate p : proto.getPredicateList()) { + list.add(UnifiedPredicate.create(p)); + } + this.predicates = ImmutableList.copyOf(list); + } + + @Override + boolean matches(MatchingData data) { + for (UnifiedPredicate p : predicates) { + if (!p.matches(data)) { + return false; + } + } + return true; + } + } + + private static class NotPredicate extends UnifiedPredicate { + private final UnifiedPredicate predicate; + + NotPredicate(Predicate proto) { + this.predicate = UnifiedPredicate.create(proto); + } + + @Override + boolean matches(MatchingData data) { + return !predicate.matches(data); + } + } +} diff --git a/xds/src/test/java/io/grpc/xds/CompositeFilterTest.java b/xds/src/test/java/io/grpc/xds/CompositeFilterTest.java new file mode 100644 index 00000000000..c91c3bd6284 --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/CompositeFilterTest.java @@ -0,0 +1,1108 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed 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 + * + * http://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 io.grpc.xds; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.github.xds.type.matcher.v3.Matcher; +import com.github.xds.type.matcher.v3.StringMatcher; +import com.google.protobuf.Any; +import io.envoyproxy.envoy.config.core.v3.TypedExtensionConfig; +import io.envoyproxy.envoy.extensions.common.matching.v3.ExtensionWithMatcher; +import io.envoyproxy.envoy.extensions.common.matching.v3.ExtensionWithMatcherPerRoute; +import io.envoyproxy.envoy.extensions.filters.http.composite.v3.Composite; +import io.envoyproxy.envoy.extensions.filters.http.composite.v3.ExecuteFilterAction; +import io.envoyproxy.envoy.type.matcher.v3.HttpRequestHeaderMatchInput; +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.Metadata; +import io.grpc.MethodDescriptor; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.Status; +import io.grpc.xds.Filter.FilterConfig; +import io.grpc.xds.internal.UnifiedMatcher; +import java.util.concurrent.ScheduledExecutorService; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +@RunWith(JUnit4.class) +@SuppressWarnings({ "rawtypes", "unchecked" }) +public class CompositeFilterTest { + + private final CompositeFilter.Provider provider = new CompositeFilter.Provider(); + + @Mock + private Filter.Provider fakeProvider; + @Mock + private Filter fakeFilter; + @Mock + private ClientInterceptor fakeClientInterceptor; + @Mock + private ServerInterceptor fakeServerInterceptor; + @Mock + private FilterConfig fakeConfig; + + private static final String FAKE_TYPE_URL = "type.googleapis.com/fake"; + + @Before + @SuppressWarnings("deprecation") + public void setUp() { + MockitoAnnotations.initMocks(this); + System.setProperty("GRPC_EXPERIMENTAL_XDS_COMPOSITE_FILTER", "true"); + + when(fakeProvider.typeUrls()).thenReturn(new String[]{FAKE_TYPE_URL}); + ConfigOrError configRes = ConfigOrError.fromConfig(fakeConfig); + when(fakeProvider.parseFilterConfig(any(Any.class))) + .thenReturn((ConfigOrError) configRes); + when(fakeProvider.newInstance(any(String.class))).thenReturn(fakeFilter); + when(fakeFilter.buildClientInterceptor(any(), any(), any())).thenReturn(fakeClientInterceptor); + when(fakeFilter.buildServerInterceptor(any(), any())).thenReturn(fakeServerInterceptor); + + FilterRegistry.getDefaultRegistry().register(fakeProvider); + } + + @After + public void tearDown() { + System.clearProperty("GRPC_EXPERIMENTAL_XDS_COMPOSITE_FILTER"); + FilterRegistry.getDefaultRegistry().deregister(fakeProvider); + } + + @Test + public void parseConfig() { + Matcher.OnMatch matchAction = Matcher.OnMatch.newBuilder() + .setAction(com.github.xds.core.v3.TypedExtensionConfig.newBuilder() + .setName("action") + .setTypedConfig(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.filters.http" + + ".composite.v3.ExecuteFilterAction") + .setValue(ExecuteFilterAction.newBuilder() + .setTypedConfig(TypedExtensionConfig + .newBuilder() + .setName("child") + .setTypedConfig(Any + .newBuilder() + .setTypeUrl(FAKE_TYPE_URL) + .setValue(Composite + .newBuilder() + .build() + .toByteString()) + .build()) + .build()) + .build().toByteString()) + .build()) + .build()) + .build(); + + Matcher matcher = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate( + Matcher.MatcherList.Predicate.SinglePredicate + .newBuilder() + .setInput(com.github.xds.core.v3.TypedExtensionConfig + .newBuilder() + .setName("request_headers") + .setTypedConfig( + Any.pack( + io.envoyproxy.envoy.type.matcher.v3 + .HttpRequestHeaderMatchInput + .newBuilder() + .setHeaderName("foo") + .build())) + .build()) + .setValueMatch(StringMatcher + .newBuilder() + .setExact("bar") + .build()) + .build()) + .build()) + .setOnMatch(matchAction) + .build()) + .build()) + .build(); + + ExtensionWithMatcher proto = ExtensionWithMatcher.newBuilder() + .setExtensionConfig(TypedExtensionConfig.newBuilder().setName("composite").build()) + .setXdsMatcher(matcher) + .build(); + + ConfigOrError result = provider + .parseFilterConfig(Any.pack(proto)); + + assertThat(result.errorDetail).isNull(); + assertThat(result.config).isNotNull(); + assertThat(result.config.matcher).isNotNull(); + } + + @Test + public void clientInterceptorDelegates() { + // Setup Config with simple matcher equivalent logic + Matcher.OnMatch matchAction = Matcher.OnMatch.newBuilder() + .setAction(com.github.xds.core.v3.TypedExtensionConfig.newBuilder() + .setName("action") + .setTypedConfig(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.filters.http.composite" + + ".v3.ExecuteFilterAction") + .setValue(ExecuteFilterAction.newBuilder() + .setTypedConfig(TypedExtensionConfig + .newBuilder() + .setName("child") + .setTypedConfig(Any + .newBuilder() + .setTypeUrl(FAKE_TYPE_URL) + .setValue(Composite + .newBuilder() + .build() + .toByteString()) + .build()) + .build()) + .build().toByteString()) + .build()) + .build()) + .build(); + + Matcher matcher = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate( + Matcher.MatcherList.Predicate.SinglePredicate + .newBuilder() + .setInput(com.github.xds.core.v3.TypedExtensionConfig + .newBuilder() + .setName("request_headers") + .setTypedConfig( + Any.pack( + io.envoyproxy.envoy.type.matcher.v3 + .HttpRequestHeaderMatchInput + .newBuilder() + .setHeaderName("foo") + .build())) + .build()) + .setValueMatch(StringMatcher + .newBuilder() + .setExact("bar") + .build()) + .build()) + .build()) + .setOnMatch(matchAction) + .build()) + .build()) + .build(); + + ExtensionWithMatcher proto = ExtensionWithMatcher.newBuilder() + .setExtensionConfig(TypedExtensionConfig.newBuilder().setName("composite").build()) + .setXdsMatcher(matcher) + .build(); + + ConfigOrError result = provider + .parseFilterConfig(Any.pack(proto)); + + assertThat(result.errorDetail).isNull(); + assertThat(result.config).isNotNull(); + + CompositeFilter filter = (CompositeFilter) provider.newInstance("composite"); + ClientInterceptor interceptor = filter.buildClientInterceptor(result.config, null, + mock(ScheduledExecutorService.class)); + + Channel next = mock(Channel.class); + MethodDescriptor.Marshaller marshaller = mock(MethodDescriptor.Marshaller.class); + MethodDescriptor method = MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("service/method") + .setRequestMarshaller(marshaller) + .setResponseMarshaller(marshaller) + .build(); + CallOptions callOptions = CallOptions.DEFAULT; + + ClientCall call = interceptor.interceptCall(method, callOptions, next); + + // Setup Fake Child Interceptor behavior + ClientCall childCall = mock(ClientCall.class); + when(fakeClientInterceptor.interceptCall(any(), any(), any())).thenReturn(childCall); + + // Start with matching headers + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("foo", Metadata.ASCII_STRING_MARSHALLER), "bar"); + + call.start(mock(ClientCall.Listener.class), headers); + + verify(fakeClientInterceptor).interceptCall(any(), any(), any()); + verify(childCall).start(any(), eq(headers)); + } + + @Test + public void clientInterceptorSkips() { + // Setup Config with simple matcher equivalent logic with no match + Matcher matcher = Matcher.newBuilder().build(); + + ExtensionWithMatcher proto = ExtensionWithMatcher.newBuilder() + .setExtensionConfig(TypedExtensionConfig.newBuilder().setName("composite").build()) + .setXdsMatcher(matcher) + .build(); + + ConfigOrError result = provider + .parseFilterConfig(Any.pack(proto)); + + CompositeFilter filter = (CompositeFilter) provider.newInstance("composite"); + ClientInterceptor interceptor = filter.buildClientInterceptor(result.config, null, + mock(ScheduledExecutorService.class)); + + Channel next = mock(Channel.class); + ClientCall nextCall = mock(ClientCall.class); + when(next.newCall(any(), any())).thenReturn(nextCall); + + MethodDescriptor.Marshaller marshaller = mock(MethodDescriptor.Marshaller.class); + MethodDescriptor method = MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("service/method") + .setRequestMarshaller(marshaller) + .setResponseMarshaller(marshaller) + .build(); + + ClientCall call = interceptor.interceptCall(method, CallOptions.DEFAULT, next); + + Metadata headers = new Metadata(); + call.start(mock(ClientCall.Listener.class), headers); + + verify(fakeClientInterceptor, org.mockito.Mockito.never()).interceptCall(any(), any(), any()); + verify(next).newCall(any(), any()); + verify(nextCall).start(any(), eq(headers)); + } + + @Test + public void clientInterceptorDelegatesChain() { + // Setup Chain Action + TypedExtensionConfig child1 = TypedExtensionConfig.newBuilder() + .setName("child1") + .setTypedConfig(Any.newBuilder() + .setTypeUrl(FAKE_TYPE_URL) + .setValue(Composite.newBuilder().build().toByteString()) + .build()) + .build(); + TypedExtensionConfig child2 = TypedExtensionConfig.newBuilder() + .setName("child2") + .setTypedConfig(Any.newBuilder() + .setTypeUrl(FAKE_TYPE_URL) + .setValue(Composite.newBuilder().build().toByteString()) + .build()) + .build(); + + io.envoyproxy.envoy.extensions.filters.http.composite.v3.FilterChainConfiguration filterChain = + io.envoyproxy.envoy.extensions.filters.http.composite.v3.FilterChainConfiguration + .newBuilder() + .addTypedConfig(child1) + .addTypedConfig(child2) + .build(); + + Matcher.OnMatch matchAction = Matcher.OnMatch.newBuilder() + .setAction(com.github.xds.core.v3.TypedExtensionConfig.newBuilder() + .setName("action") + .setTypedConfig(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.filters.http.composite" + + ".v3.ExecuteFilterAction") + .setValue(ExecuteFilterAction.newBuilder() + .setFilterChain(filterChain) + .build().toByteString()) + .build()) + .build()) + .build(); + + Matcher matcher = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate( + Matcher.MatcherList.Predicate.SinglePredicate + .newBuilder() + .setInput(com.github.xds.core.v3.TypedExtensionConfig + .newBuilder() + .setName("request_headers") + .setTypedConfig( + Any.pack( + io.envoyproxy.envoy.type.matcher.v3 + .HttpRequestHeaderMatchInput + .newBuilder() + .setHeaderName("foo") + .build())) + .build()) + .setValueMatch(StringMatcher + .newBuilder() + .setExact("bar") + .build()) + .build()) + .build()) + .setOnMatch(matchAction) + .build()) + .build()) + .build(); + + ExtensionWithMatcher proto = ExtensionWithMatcher.newBuilder() + .setExtensionConfig(TypedExtensionConfig.newBuilder().setName("composite").build()) + .setXdsMatcher(matcher) + .build(); + + ConfigOrError result = provider + .parseFilterConfig(Any.pack(proto)); + + assertThat(result.errorDetail).isNull(); + CompositeFilter filter = (CompositeFilter) provider.newInstance("composite"); + ClientInterceptor interceptor = filter.buildClientInterceptor(result.config, null, + mock(ScheduledExecutorService.class)); + + Channel next = mock(Channel.class); + ClientCall nextCall = mock(ClientCall.class); + when(next.newCall(any(), any())).thenReturn(nextCall); + MethodDescriptor.Marshaller marshaller = mock(MethodDescriptor.Marshaller.class); + MethodDescriptor method = MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("service/method") + .setRequestMarshaller(marshaller) + .setResponseMarshaller(marshaller) + .build(); + CallOptions callOptions = CallOptions.DEFAULT; + + ClientCall call = interceptor.interceptCall(method, callOptions, next); + + // Setup Fake Child Interceptor behavior + // Re-create mock to ensure we have control + fakeClientInterceptor = mock(ClientInterceptor.class); + when(fakeFilter.buildClientInterceptor(any(), any(), any())).thenReturn(fakeClientInterceptor); + + org.mockito.Mockito.doAnswer(invocation -> { + io.grpc.Channel nextArg = (io.grpc.Channel) invocation.getArguments()[2]; + return nextArg.newCall( + (io.grpc.MethodDescriptor) invocation.getArguments()[0], + (io.grpc.CallOptions) invocation.getArguments()[1]); + }).when(fakeClientInterceptor).interceptCall(any(), any(), any()); + + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("foo", Metadata.ASCII_STRING_MARSHALLER), "bar"); + + call.start(mock(ClientCall.Listener.class), headers); + + // Verify buildClientInterceptor was called twice (once per child in chain) + verify(fakeFilter, org.mockito.Mockito.times(2)) + .buildClientInterceptor(any(), any(), any()); + // Verify interceptCall was called twice on the fake interceptor (since it is + // reused) + // Actually, if we reuse the same interceptor instance "fakeClientInterceptor", + // interceptCall is called on IT. + // But wait, chaining wraps them. + // int1(next=int2) -> int2(next=channel) + // If int1 and int2 are SAME instance. + // call.start calls int1.interceptCall + // int1 calls next.newCall + // next is int2-channel-wrapper? No. + // ClientInterceptors.intercept(channel, [i1, i2]) + // Sequence: i2 intercepts channel. i1 intercepts i2-channel. + // i1.interceptCall(next=i2-channel) called. + // i1 calls next.newCall() -> i2.interceptCall(next=channel) called. + // So yes, interceptCall should be called twice on the interceptor instance. + verify(fakeClientInterceptor, org.mockito.Mockito.times(2)) + .interceptCall(any(), any(), any()); + } + + @Test + public void serverNameInputMatch() { + // Setup matcher with ServerNameInput + Matcher.OnMatch matchAction = Matcher.OnMatch.newBuilder() + .setAction(com.github.xds.core.v3.TypedExtensionConfig.newBuilder() + .setName("action") + .setTypedConfig(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.filters.http.composite" + + ".v3.ExecuteFilterAction") + .setValue(ExecuteFilterAction.newBuilder() + .setTypedConfig(TypedExtensionConfig + .newBuilder() + .setName("child") + .setTypedConfig(Any + .newBuilder() + .setTypeUrl(FAKE_TYPE_URL) + .setValue(Composite + .newBuilder() + .build() + .toByteString()) + .build()) + .build()) + .build().toByteString()) + .build()) + .build()) + .build(); + + Matcher matcher = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate( + Matcher.MatcherList.Predicate.SinglePredicate + .newBuilder() + .setInput(com.github.xds.core.v3.TypedExtensionConfig + .newBuilder() + .setName("server_name") + .setTypedConfig( + Any.pack( + io.envoyproxy.envoy.extensions.matching.common_inputs + .network.v3.ServerNameInput + .newBuilder() + .build())) + .build()) + .setValueMatch(StringMatcher + .newBuilder() + .setExact("foo.com") + .build()) + .build()) + .build()) + .setOnMatch(matchAction) + .build()) + .build()) + .build(); + + ExtensionWithMatcher proto = ExtensionWithMatcher.newBuilder() + .setExtensionConfig(TypedExtensionConfig.newBuilder().setName("composite").build()) + .setXdsMatcher(matcher) + .build(); + + ConfigOrError result = provider + .parseFilterConfig(Any.pack(proto)); + + assertThat(result.errorDetail).isNull(); + + CompositeFilter filter = (CompositeFilter) provider.newInstance("composite"); + ClientInterceptor interceptor = filter.buildClientInterceptor(result.config, null, + mock(ScheduledExecutorService.class)); + + Channel next = mock(Channel.class); + ClientCall nextCall = mock(ClientCall.class); + when(next.newCall(any(), any())).thenReturn(nextCall); + + MethodDescriptor.Marshaller marshaller = mock(MethodDescriptor.Marshaller.class); + MethodDescriptor method = MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("service/method") + .setRequestMarshaller(marshaller) + .setResponseMarshaller(marshaller) + .build(); + CallOptions callOptions = CallOptions.DEFAULT.withAuthority("foo.com"); + + ClientCall call = interceptor.interceptCall(method, callOptions, next); + + // Setup Fake Child Interceptor behavior + ClientCall childCall = mock(ClientCall.class); + when(fakeClientInterceptor.interceptCall(any(), any(), any())).thenReturn(childCall); + + Metadata headers = new Metadata(); + call.start(mock(ClientCall.Listener.class), headers); + + // Should match and call the child interceptor + verify(fakeClientInterceptor).interceptCall(any(), any(), any()); + verify(childCall).start(any(), eq(headers)); + } + + @Test + public void samplePercentAlwaysFalse() { + // Mock random to always return false (fail sampling) + // We can't easily mock ThreadLocalRandom.current() without Powermock or + // similar. + // But we can rely on 0% sampling. + + Matcher.OnMatch matchAction = Matcher.OnMatch.newBuilder() + .setAction(com.github.xds.core.v3.TypedExtensionConfig.newBuilder() + .setName("action") + .setTypedConfig(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.filters.http.composite" + + ".v3.ExecuteFilterAction") + .setValue(ExecuteFilterAction.newBuilder() + .setTypedConfig(TypedExtensionConfig + .newBuilder() + .setName("child") + .setTypedConfig(Any + .newBuilder() + .setTypeUrl(FAKE_TYPE_URL) + .setValue(Composite + .newBuilder() + .build() + .toByteString()) + .build()) + .build()) + .setSamplePercent( + io.envoyproxy.envoy.config.core.v3.RuntimeFractionalPercent + .newBuilder() + .setDefaultValue( + io.envoyproxy.envoy.type.v3.FractionalPercent + .newBuilder() + .setNumerator(0) + .setDenominator( + io.envoyproxy.envoy.type.v3.FractionalPercent + .DenominatorType.HUNDRED) + .build()) + .build()) + .build().toByteString()) + .build()) + .build()) + .build(); + + Matcher matcher = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate( + Matcher.MatcherList.Predicate.SinglePredicate + .newBuilder() + .setInput(com.github.xds.core.v3.TypedExtensionConfig + .newBuilder() + .setName("request_headers") + .setTypedConfig( + Any.pack( + io.envoyproxy.envoy.type.matcher.v3 + .HttpRequestHeaderMatchInput + .newBuilder() + .setHeaderName("foo") + .build())) + .build()) + .setValueMatch(StringMatcher + .newBuilder() + .setExact("bar") + .build()) + .build()) + .build()) + .setOnMatch(matchAction) + .build()) + .build()) + .build(); + + ExtensionWithMatcher proto = ExtensionWithMatcher.newBuilder() + .setExtensionConfig(TypedExtensionConfig.newBuilder().setName("composite").build()) + .setXdsMatcher(matcher) + .build(); + + ConfigOrError result = provider + .parseFilterConfig(Any.pack(proto)); + + assertThat(result.errorDetail).isNull(); + + CompositeFilter filter = (CompositeFilter) provider.newInstance("composite"); + ClientInterceptor interceptor = filter.buildClientInterceptor(result.config, null, + mock(ScheduledExecutorService.class)); + + Channel next = mock(Channel.class); + ClientCall nextCall = mock(ClientCall.class); + when(next.newCall(any(), any())).thenReturn(nextCall); + + MethodDescriptor.Marshaller marshaller = mock(MethodDescriptor.Marshaller.class); + MethodDescriptor method = MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("service/method") + .setRequestMarshaller(marshaller) + .setResponseMarshaller(marshaller) + .build(); + + // Match the header, but should fail sampling + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("foo", Metadata.ASCII_STRING_MARSHALLER), "bar"); + + ClientCall call = interceptor.interceptCall(method, CallOptions.DEFAULT, next); + call.start(mock(ClientCall.Listener.class), headers); + + verify(fakeClientInterceptor, org.mockito.Mockito.never()).interceptCall(any(), any(), any()); + verify(next).newCall(any(), any()); + } + + @Test + public void clientCallMethodsThrowBeforeStart() { + CompositeFilter filter = (CompositeFilter) provider.newInstance("composite"); + UnifiedMatcher mockMatcher = mock(UnifiedMatcher.class); + ClientInterceptor interceptor = filter.buildClientInterceptor( + new CompositeFilter.CompositeFilterConfig(mockMatcher), null, + mock(ScheduledExecutorService.class)); + + Channel next = mock(Channel.class); + MethodDescriptor.Marshaller marshaller = mock(MethodDescriptor.Marshaller.class); + MethodDescriptor method = MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("service/method") + .setRequestMarshaller(marshaller) + .setResponseMarshaller(marshaller) + .build(); + + ClientCall call = interceptor.interceptCall(method, CallOptions.DEFAULT, next); + + // Call request before start should throw IllegalStateException + try { + call.request(1); + org.junit.Assert.fail("Expected IllegalStateException"); + } catch (IllegalStateException e) { + assertThat(e.getMessage()).isEqualTo("Not started"); + } + + // Call halfClose before start should throw IllegalStateException + try { + call.halfClose(); + org.junit.Assert.fail("Expected IllegalStateException"); + } catch (IllegalStateException e) { + assertThat(e.getMessage()).isEqualTo("Not started"); + } + + // Call sendMessage before start should throw IllegalStateException + try { + call.sendMessage(null); + org.junit.Assert.fail("Expected IllegalStateException"); + } catch (IllegalStateException e) { + assertThat(e.getMessage()).isEqualTo("Not started"); + } + + // Call setMessageCompression before start should throw IllegalStateException + try { + call.setMessageCompression(true); + org.junit.Assert.fail("Expected IllegalStateException"); + } catch (IllegalStateException e) { + assertThat(e.getMessage()).isEqualTo("Not started"); + } + + // Cancel is allowed before start and should not throw + call.cancel("message", null); + } + + @Test + public void parseFilterConfigRejectsOverrideMessage() { + ExtensionWithMatcherPerRoute overrideProto = ExtensionWithMatcherPerRoute.newBuilder() + .setXdsMatcher(Matcher.newBuilder().build()) + .build(); + + ConfigOrError result = provider + .parseFilterConfig(Any.pack(overrideProto)); + + assertThat(result.errorDetail).contains("Unsupported message type in parseFilterConfig"); + } + + @Test + public void parseFilterConfigOverrideRejectsConfigMessage() { + ExtensionWithMatcher configProto = ExtensionWithMatcher.newBuilder() + .setXdsMatcher(Matcher.newBuilder().build()) + .build(); + + ConfigOrError result = provider + .parseFilterConfigOverride(Any.pack(configProto)); + + assertThat(result.errorDetail).contains("Unsupported message type in" + + " parseFilterConfigOverride"); + } + + @Test + public void parseFilterConfigFailsWhenDisabled() { + System.clearProperty("GRPC_EXPERIMENTAL_XDS_COMPOSITE_FILTER"); + try { + ConfigOrError result = provider + .parseFilterConfig(Any.pack(Composite.getDefaultInstance())); + assertThat(result.errorDetail).contains("Composite Filter is experimental"); + } finally { + System.setProperty("GRPC_EXPERIMENTAL_XDS_COMPOSITE_FILTER", "true"); + } + } + + @Test + public void parseFilterConfigWithEmptyConfig() { + ConfigOrError result = provider + .parseFilterConfig(Any.pack(Composite.getDefaultInstance())); + + assertThat(result.errorDetail).isNull(); + assertThat(result.config.matcher).isNull(); + } + + @Test + public void clientInterceptorUsesOverrideMatcher() { + // Base matcher that matches "foo=bar" + Matcher.OnMatch matchAction = Matcher.OnMatch.newBuilder() + .setAction(com.github.xds.core.v3.TypedExtensionConfig.newBuilder() + .setName("action") + .setTypedConfig(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.filters.http.composite" + + ".v3.ExecuteFilterAction") + .setValue(ExecuteFilterAction.newBuilder() + .setTypedConfig(TypedExtensionConfig.newBuilder() + .setName("child") + .setTypedConfig(Any.newBuilder().setTypeUrl(FAKE_TYPE_URL).build()) + .build()) + .build().toByteString()) + .build()) + .build()) + .build(); + + Matcher baseMatcherProto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(com.github.xds.core.v3.TypedExtensionConfig.newBuilder() + .setName("request_headers") + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("foo") + .build())) + .build()) + .setValueMatch(StringMatcher.newBuilder().setExact("bar").build()) + .build()) + .build()) + .setOnMatch(matchAction) + .build()) + .build()) + .build(); + + ExtensionWithMatcher baseProto = ExtensionWithMatcher.newBuilder() + .setExtensionConfig(TypedExtensionConfig.newBuilder().setName("composite").build()) + .setXdsMatcher(baseMatcherProto) + .build(); + + ConfigOrError baseResult = provider + .parseFilterConfig(Any.pack(baseProto)); + + // Override matcher that is empty (skips everything) + Matcher overrideMatcherProto = Matcher.newBuilder().build(); + ExtensionWithMatcherPerRoute overrideProto = ExtensionWithMatcherPerRoute.newBuilder() + .setXdsMatcher(overrideMatcherProto) + .build(); + + ConfigOrError overrideResult = provider + .parseFilterConfigOverride(Any.pack(overrideProto)); + + CompositeFilter filter = (CompositeFilter) provider.newInstance("composite"); + ClientInterceptor interceptor = filter.buildClientInterceptor( + baseResult.config, overrideResult.config, mock(ScheduledExecutorService.class)); + + Channel next = mock(Channel.class); + ClientCall nextCall = mock(ClientCall.class); + when(next.newCall(any(), any())).thenReturn(nextCall); + + MethodDescriptor.Marshaller marshaller = mock(MethodDescriptor.Marshaller.class); + MethodDescriptor method = MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("service/method") + .setRequestMarshaller(marshaller) + .setResponseMarshaller(marshaller) + .build(); + + ClientCall call = interceptor.interceptCall(method, CallOptions.DEFAULT, next); + + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("foo", Metadata.ASCII_STRING_MARSHALLER), "bar"); + // This would match base, but should be overridden + + call.start(mock(ClientCall.Listener.class), headers); + + // Verify it SKIPS (calls next directly, never calls fakeClientInterceptor) + verify(fakeClientInterceptor, org.mockito.Mockito.never()).interceptCall(any(), any(), any()); + verify(next).newCall(any(), any()); + verify(nextCall).start(any(), eq(headers)); + } + + @Test + public void serverInterceptorDelegates() { + // Setup Config with simple matcher equivalent logic + Matcher.OnMatch matchAction = Matcher.OnMatch.newBuilder() + .setAction(com.github.xds.core.v3.TypedExtensionConfig.newBuilder() + .setName("action") + .setTypedConfig(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.filters.http.composite" + + ".v3.ExecuteFilterAction") + .setValue(ExecuteFilterAction.newBuilder() + .setTypedConfig(TypedExtensionConfig.newBuilder() + .setName("child") + .setTypedConfig(Any.newBuilder().setTypeUrl(FAKE_TYPE_URL).build()) + .build()) + .build().toByteString()) + .build()) + .build()) + .build(); + + Matcher matcher = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(com.github.xds.core.v3.TypedExtensionConfig.newBuilder() + .setName("request_headers") + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("foo") + .build())) + .build()) + .setValueMatch(StringMatcher.newBuilder().setExact("bar").build()) + .build()) + .build()) + .setOnMatch(matchAction) + .build()) + .build()) + .build(); + + ExtensionWithMatcher proto = ExtensionWithMatcher.newBuilder() + .setExtensionConfig(TypedExtensionConfig.newBuilder().setName("composite").build()) + .setXdsMatcher(matcher) + .build(); + + ConfigOrError result = provider + .parseFilterConfig(Any.pack(proto)); + + CompositeFilter filter = (CompositeFilter) provider.newInstance("composite"); + ServerInterceptor interceptor = filter.buildServerInterceptor(result.config, null); + + ServerCall call = mock(ServerCall.class); + when(call.getAttributes()).thenReturn(io.grpc.Attributes.EMPTY); + + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("foo", Metadata.ASCII_STRING_MARSHALLER), "bar"); + + ServerCallHandler next = mock(ServerCallHandler.class); + ServerCall.Listener listener = mock(ServerCall.Listener.class); + when(next.startCall(any(), any())).thenReturn(listener); + + interceptor.interceptCall(call, headers, next); + + verify(fakeServerInterceptor).interceptCall(eq(call), eq(headers), any()); + } + + @Test + public void clientInterceptorClosesFiltersOnClose() { + // Setup Config with simple matcher equivalent logic + Matcher.OnMatch matchAction = Matcher.OnMatch.newBuilder() + .setAction(com.github.xds.core.v3.TypedExtensionConfig.newBuilder() + .setName("action") + .setTypedConfig(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.filters.http.composite" + + ".v3.ExecuteFilterAction") + .setValue(ExecuteFilterAction.newBuilder() + .setTypedConfig(TypedExtensionConfig.newBuilder() + .setName("child") + .setTypedConfig(Any.newBuilder().setTypeUrl(FAKE_TYPE_URL).build()) + .build()) + .build().toByteString()) + .build()) + .build()) + .build(); + + Matcher matcher = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(com.github.xds.core.v3.TypedExtensionConfig.newBuilder() + .setName("request_headers") + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("foo") + .build())) + .build()) + .setValueMatch(StringMatcher.newBuilder().setExact("bar").build()) + .build()) + .build()) + .setOnMatch(matchAction) + .build()) + .build()) + .build(); + + ExtensionWithMatcher proto = ExtensionWithMatcher.newBuilder() + .setExtensionConfig(TypedExtensionConfig.newBuilder().setName("composite").build()) + .setXdsMatcher(matcher) + .build(); + + ConfigOrError result = provider + .parseFilterConfig(Any.pack(proto)); + + CompositeFilter filter = (CompositeFilter) provider.newInstance("composite"); + ClientInterceptor interceptor = filter.buildClientInterceptor(result.config, null, + mock(ScheduledExecutorService.class)); + + Channel next = mock(Channel.class); + ClientCall childCall = mock(ClientCall.class); + when(fakeClientInterceptor.interceptCall(any(), any(), any())).thenReturn(childCall); + + MethodDescriptor.Marshaller marshaller = mock(MethodDescriptor.Marshaller.class); + MethodDescriptor method = MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("service/method") + .setRequestMarshaller(marshaller) + .setResponseMarshaller(marshaller) + .build(); + + ClientCall call = interceptor.interceptCall(method, CallOptions.DEFAULT, next); + + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("foo", Metadata.ASCII_STRING_MARSHALLER), "bar"); + + ClientCall.Listener responseListener = mock(ClientCall.Listener.class); + call.start(responseListener, headers); + + // Capture the listener passed to childCall + ArgumentCaptor listenerCaptor = + ArgumentCaptor.forClass(ClientCall.Listener.class); + verify(childCall).start(listenerCaptor.capture(), eq(headers)); + + ClientCall.Listener capturedListener = listenerCaptor.getValue(); + + // Trigger onClose + capturedListener.onClose(Status.OK, new Metadata()); + + // Verify filter.close() was called + verify(fakeFilter).close(); + } + + @Test + public void parseFilterConfigExceedsRecursionLimit() { + // Setup matcher that resolves to fakeProvider + Matcher.OnMatch matchAction = Matcher.OnMatch.newBuilder() + .setAction(com.github.xds.core.v3.TypedExtensionConfig.newBuilder() + .setName("action") + .setTypedConfig(Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.filters.http.composite" + + ".v3.ExecuteFilterAction") + .setValue(ExecuteFilterAction.newBuilder() + .setTypedConfig(TypedExtensionConfig.newBuilder() + .setName("child") + .setTypedConfig(Any.newBuilder().setTypeUrl(FAKE_TYPE_URL).build()) + .build()) + .build().toByteString()) + .build()) + .build()) + .build(); + + Matcher matcherProto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setOnMatch(matchAction) + .build()) + .build()) + .build(); + + ExtensionWithMatcher configProto = ExtensionWithMatcher.newBuilder() + .setExtensionConfig(TypedExtensionConfig.newBuilder().setName("composite").build()) + .setXdsMatcher(matcherProto) + .build(); + + final Any configAny = Any.pack(configProto); + + // Mock fakeProvider to call provider.parseFilterConfig recursively + when(fakeProvider.parseFilterConfig(any())) + .thenAnswer(new org.mockito.stubbing.Answer() { + private int depth = 0; + @Override + public ConfigOrError answer( + org.mockito.invocation.InvocationOnMock invocation) throws Throwable { + depth++; + if (depth > 15) { // Safety break + return ConfigOrError.fromError("Infinite recursion safety break"); + } + return provider.parseFilterConfig(configAny); + } + }); + + ConfigOrError result = provider + .parseFilterConfig(configAny); + + assertThat(result.errorDetail).contains("Maximum recursion depth of 8 exceeded"); + } + + @Test + public void clientInterceptorSkipsOnSkipFilter() { + // Setup Config with a matcher that MATCHES, but action is SkipFilter + + Any skipActionAny = Any.newBuilder() + .setTypeUrl("type.googleapis.com/envoy.extensions.filters.common.matcher.action" + + ".v3.SkipFilter") + .setValue(com.google.protobuf.ByteString.EMPTY) + .build(); + + Matcher.OnMatch matchAction = Matcher.OnMatch.newBuilder() + .setAction(com.github.xds.core.v3.TypedExtensionConfig.newBuilder() + .setName("action") + .setTypedConfig(skipActionAny) + .build()) + .build(); + + Matcher matcher = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(com.github.xds.core.v3.TypedExtensionConfig.newBuilder() + .setName("request_headers") + .setTypedConfig(Any.pack( + HttpRequestHeaderMatchInput.newBuilder() + .setHeaderName("foo") + .build())) + .build()) + .setValueMatch(StringMatcher.newBuilder().setExact("bar").build()) + .build()) + .build()) + .setOnMatch(matchAction) + .build()) + .build()) + .build(); + + ExtensionWithMatcher proto = ExtensionWithMatcher.newBuilder() + .setExtensionConfig(TypedExtensionConfig.newBuilder().setName("composite").build()) + .setXdsMatcher(matcher) + .build(); + + ConfigOrError result = provider + .parseFilterConfig(Any.pack(proto)); + + assertThat(result.errorDetail).isNull(); + + CompositeFilter filter = (CompositeFilter) provider.newInstance("composite"); + ClientInterceptor interceptor = filter.buildClientInterceptor(result.config, null, + mock(ScheduledExecutorService.class)); + + Channel next = mock(Channel.class); + ClientCall nextCall = mock(ClientCall.class); + when(next.newCall(any(), any())).thenReturn(nextCall); + + MethodDescriptor.Marshaller marshaller = mock(MethodDescriptor.Marshaller.class); + MethodDescriptor method = MethodDescriptor.newBuilder() + .setType(MethodDescriptor.MethodType.UNARY) + .setFullMethodName("service/method") + .setRequestMarshaller(marshaller) + .setResponseMarshaller(marshaller) + .build(); + + ClientCall call = interceptor.interceptCall(method, CallOptions.DEFAULT, next); + + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("foo", Metadata.ASCII_STRING_MARSHALLER), "bar"); // This matches + + call.start(mock(ClientCall.Listener.class), headers); + + // Verify it SKIPS (calls next directly, never calls fakeClientInterceptor) + verify(fakeClientInterceptor, org.mockito.Mockito.never()).interceptCall(any(), any(), any()); + verify(next).newCall(any(), any()); + verify(nextCall).start(any(), eq(headers)); + } +} diff --git a/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplDataTest.java b/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplDataTest.java index a1b1adae17f..bc6d789ab0d 100644 --- a/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplDataTest.java +++ b/xds/src/test/java/io/grpc/xds/GrpcXdsClientImplDataTest.java @@ -2923,7 +2923,8 @@ public void parseFilterChain_noHcm() throws ResourceInvalidException { filterChain, "filter-chain-foo", null, filterRegistry, null, null, getXdsResourceTypeArgs(true))); assertThat(e).hasMessageThat().isEqualTo( - "FilterChain filter-chain-foo should contain exact one HttpConnectionManager filter"); + "FilterChain filter-chain-foo should contain exactly" + + " one HttpConnectionManager filter"); } @Test @@ -2942,7 +2943,8 @@ public void parseFilterChain_duplicateFilter() throws ResourceInvalidException { filterChain, "filter-chain-foo", null, filterRegistry, null, null, getXdsResourceTypeArgs(true))); assertThat(e).hasMessageThat().isEqualTo( - "FilterChain filter-chain-foo should contain exact one HttpConnectionManager filter"); + "FilterChain filter-chain-foo should contain exactly" + + " one HttpConnectionManager filter"); } @Test diff --git a/xds/src/test/java/io/grpc/xds/LoadBalancerConfigFactoryTest.java b/xds/src/test/java/io/grpc/xds/LoadBalancerConfigFactoryTest.java index b8b20248026..ca8bc35ba9c 100644 --- a/xds/src/test/java/io/grpc/xds/LoadBalancerConfigFactoryTest.java +++ b/xds/src/test/java/io/grpc/xds/LoadBalancerConfigFactoryTest.java @@ -129,7 +129,7 @@ public class LoadBalancerConfigFactoryTest { .build()))).build(); private static final Policy CUSTOM_POLICY_UDPA = Policy.newBuilder().setTypedExtensionConfig( TypedExtensionConfig.newBuilder().setTypedConfig(Any.pack( - com.github.udpa.udpa.type.v1.TypedStruct.newBuilder().setTypeUrl( + TypedStruct.newBuilder().setTypeUrl( "type.googleapis.com/" + CUSTOM_POLICY_NAME).setValue( Struct.newBuilder().putFields(CUSTOM_POLICY_FIELD_KEY, Value.newBuilder().setNumberValue(CUSTOM_POLICY_FIELD_VALUE).build())) diff --git a/xds/src/test/java/io/grpc/xds/internal/UnifiedMatcherTest.java b/xds/src/test/java/io/grpc/xds/internal/UnifiedMatcherTest.java new file mode 100644 index 00000000000..18604a733ee --- /dev/null +++ b/xds/src/test/java/io/grpc/xds/internal/UnifiedMatcherTest.java @@ -0,0 +1,177 @@ +/* + * Copyright 2026 The gRPC Authors + * + * Licensed 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 + * + * http://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 io.grpc.xds.internal; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; + +import com.github.xds.core.v3.TypedExtensionConfig; +import com.github.xds.type.matcher.v3.Matcher; +import io.grpc.xds.internal.UnifiedMatcher.MatchingData; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class UnifiedMatcherTest { + + @Test + public void testExactMatch() { + TypedExtensionConfig actionConfig = TypedExtensionConfig.newBuilder() + .setName("action1") + .build(); + Matcher.MatcherTree tree = Matcher.MatcherTree.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setName("input1")) + .setExactMatchMap(Matcher.MatcherTree.MatchMap.newBuilder() + .putMap("value1", Matcher.OnMatch.newBuilder() + .setAction(actionConfig) + .build())) + .build(); + Matcher matcherProto = Matcher.newBuilder() + .setMatcherTree(tree) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.create( + matcherProto, config -> config.getName()); + + MatchingData dataMatch = new MatchingData() { + @Override + public String getRelayedInput(TypedExtensionConfig inputConfig) { + if (inputConfig.getName().equals("input1")) { + return "value1"; + } + return null; + } + }; + + MatchingData dataNoMatch = new MatchingData() { + @Override + public String getRelayedInput(TypedExtensionConfig inputConfig) { + return "value2"; + } + }; + + assertThat(matcher.match(dataMatch)).containsExactly("action1"); + assertThat(matcher.match(dataNoMatch)).isNull(); + } + + @Test + public void testKeepMatching() { + TypedExtensionConfig actionConfig1 = TypedExtensionConfig.newBuilder() + .setName("action1") + .build(); + TypedExtensionConfig actionConfig2 = TypedExtensionConfig.newBuilder() + .setName("action2") + .build(); + + Matcher.MatcherList matcherList = Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setName("input1")) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setExact("value1")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(actionConfig1) + .setKeepMatching(true))) // keepMatching = true + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setInput(TypedExtensionConfig.newBuilder().setName("input1")) + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setExact("value1")))) + .setOnMatch(Matcher.OnMatch.newBuilder() + .setAction(actionConfig2))) // keepMatching = false (default) + .build(); + + Matcher matcherProto = Matcher.newBuilder() + .setMatcherList(matcherList) + .build(); + + UnifiedMatcher matcher = UnifiedMatcher.create( + matcherProto, config -> config.getName()); + + MatchingData dataMatch = new MatchingData() { + @Override + public String getRelayedInput(TypedExtensionConfig inputConfig) { + if (inputConfig.getName().equals("input1")) { + return "value1"; + } + return null; + } + }; + + assertThat(matcher.match(dataMatch)).containsExactly("action1", "action2").inOrder(); + } + + @Test + public void testMaxDepth16Exceeded() { + Matcher proto = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder() + .setAction(TypedExtensionConfig.getDefaultInstance())) + .build(); + for (int i = 0; i < 17; i++) { + proto = Matcher.newBuilder() + .setOnNoMatch(Matcher.OnMatch.newBuilder().setMatcher(proto).build()) + .build(); + } + final Matcher finalProto = proto; + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> UnifiedMatcher.create(finalProto, config -> config.getName())); + assertThat(e).hasMessageThat().contains("Maximum recursion depth of 16 exceeded"); + } + + @Test + public void testCustomMatchInSinglePredicateRejected() { + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setCustomMatch(TypedExtensionConfig.getDefaultInstance()))))) + .build(); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> UnifiedMatcher.create(proto, config -> config.getName())); + assertThat(e).hasMessageThat().contains("custom_match is not supported in SinglePredicate"); + } + + @Test + public void testCustomMatchInMatcherTreeRejected() { + Matcher proto = Matcher.newBuilder() + .setMatcherTree(Matcher.MatcherTree.newBuilder() + .setCustomMatch(TypedExtensionConfig.getDefaultInstance())) + .build(); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> UnifiedMatcher.create(proto, config -> config.getName())); + assertThat(e).hasMessageThat().contains("custom_match is not supported in MatcherTree"); + } + + @Test + public void testCustomStringMatcherRejected() { + Matcher proto = Matcher.newBuilder() + .setMatcherList(Matcher.MatcherList.newBuilder() + .addMatchers(Matcher.MatcherList.FieldMatcher.newBuilder() + .setPredicate(Matcher.MatcherList.Predicate.newBuilder() + .setSinglePredicate(Matcher.MatcherList.Predicate.SinglePredicate.newBuilder() + .setValueMatch(com.github.xds.type.matcher.v3.StringMatcher.newBuilder() + .setCustom(TypedExtensionConfig.getDefaultInstance())))))) + .build(); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> UnifiedMatcher.create(proto, config -> config.getName())); + assertThat(e).hasMessageThat().contains("custom string matcher is not supported"); + } +} diff --git a/xds/third_party/cel-spec/src/main/proto/cel/expr/checked.proto b/xds/third_party/cel-spec/src/main/proto/cel/expr/checked.proto index e327db9b225..014922087e4 100644 --- a/xds/third_party/cel-spec/src/main/proto/cel/expr/checked.proto +++ b/xds/third_party/cel-spec/src/main/proto/cel/expr/checked.proto @@ -38,7 +38,8 @@ message CheckedExpr { // declaration. For instance, if `a.b.c` is represented by // `select(select(id(a), b), c)`, and `a.b` resolves to a declaration, // while `c` is a field selection, then the reference is attached to the - // nested select expression (but not to the id or or the outer select). + // nested select expression (but not to the id or the outer select). + // todo: Agravator change in the origin repo instead // In turn, if `a` resolves to a declaration and `b.c` are field selections, // the reference is attached to the ident expression. // - Every Call expression has an entry here, identifying the function being diff --git a/xds/third_party/envoy/import.sh b/xds/third_party/envoy/import.sh index 74b8af750ab..74857d727b6 100755 --- a/xds/third_party/envoy/import.sh +++ b/xds/third_party/envoy/import.sh @@ -75,7 +75,9 @@ envoy/config/trace/v3/service.proto envoy/config/trace/v3/zipkin.proto envoy/data/accesslog/v3/accesslog.proto envoy/extensions/clusters/aggregate/v3/cluster.proto +envoy/extensions/common/matching/v3/extension_matcher.proto envoy/extensions/filters/common/fault/v3/fault.proto +envoy/extensions/filters/http/composite/v3/composite.proto envoy/extensions/filters/http/ext_authz/v3/ext_authz.proto envoy/extensions/common/matching/v3/extension_matcher.proto envoy/extensions/filters/http/fault/v3/fault.proto @@ -114,6 +116,7 @@ envoy/type/http/v3/path_transformation.proto envoy/type/matcher/v3/address.proto envoy/type/matcher/v3/filter_state.proto envoy/type/matcher/v3/http_inputs.proto +envoy/config/common/matcher/v3/matcher.proto envoy/type/matcher/v3/metadata.proto envoy/type/matcher/v3/node.proto envoy/config/common/matcher/v3/matcher.proto @@ -133,6 +136,7 @@ envoy/type/v3/ratelimit_strategy.proto envoy/type/v3/ratelimit_unit.proto envoy/type/v3/semantic_version.proto envoy/type/v3/token_bucket.proto +envoy/extensions/matching/common_inputs/network/v3/network_inputs.proto ) pushd "$(git rev-parse --show-toplevel)/xds/third_party/envoy" > /dev/null diff --git a/xds/third_party/envoy/src/main/proto/envoy/extensions/matching/common_inputs/network/v3/network_inputs.proto b/xds/third_party/envoy/src/main/proto/envoy/extensions/matching/common_inputs/network/v3/network_inputs.proto new file mode 100644 index 00000000000..b62690b4f26 --- /dev/null +++ b/xds/third_party/envoy/src/main/proto/envoy/extensions/matching/common_inputs/network/v3/network_inputs.proto @@ -0,0 +1,164 @@ +syntax = "proto3"; + +package envoy.extensions.matching.common_inputs.network.v3; + +import "udpa/annotations/status.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.matching.common_inputs.network.v3"; +option java_outer_classname = "NetworkInputsProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/matching/common_inputs/network/v3;networkv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: Common network matching inputs] + +// Specifies that matching should be performed by the destination IP address. +// [#extension: envoy.matching.inputs.destination_ip] +message DestinationIPInput { +} + +// Specifies that matching should be performed by the destination port. +// [#extension: envoy.matching.inputs.destination_port] +message DestinationPortInput { +} + +// Specifies that matching should be performed by the source IP address. +// [#extension: envoy.matching.inputs.source_ip] +message SourceIPInput { +} + +// Specifies that matching should be performed by the source port. +// [#extension: envoy.matching.inputs.source_port] +message SourcePortInput { +} + +// Input that matches by the directly connected source IP address (this +// will only be different from the source IP address when using a listener +// filter that overrides the source address, such as the :ref:`Proxy Protocol +// listener filter `). +// [#extension: envoy.matching.inputs.direct_source_ip] +message DirectSourceIPInput { +} + +// Input that matches by the source IP type. +// Specifies the source IP match type. The values include: +// +// * ``local`` - matches a connection originating from the same host, +// [#extension: envoy.matching.inputs.source_type] +message SourceTypeInput { +} + +// Input that matches by the requested server name (e.g. SNI in TLS). +// +// :ref:`TLS Inspector ` provides the requested server name based on SNI, +// when TLS protocol is detected. +// [#extension: envoy.matching.inputs.server_name] +message ServerNameInput { +} + +// Input that matches by the transport protocol. +// +// Suggested values include: +// +// * ``raw_buffer`` - default, used when no transport protocol is detected, +// * ``tls`` - set by :ref:`envoy.filters.listener.tls_inspector ` +// when TLS protocol is detected. +// [#extension: envoy.matching.inputs.transport_protocol] +message TransportProtocolInput { +} + +// List of quoted and comma-separated requested application protocols. The list consists of a +// single negotiated application protocol once the network stream is established. +// +// Examples: +// +// * ``'h2','http/1.1'`` +// * ``'h2c'`` +// +// Suggested values in the list include: +// +// * ``http/1.1`` - set by :ref:`envoy.filters.listener.tls_inspector +// ` and :ref:`envoy.filters.listener.http_inspector +// `, +// * ``h2`` - set by :ref:`envoy.filters.listener.tls_inspector ` +// * ``h2c`` - set by :ref:`envoy.filters.listener.http_inspector ` +// +// .. attention:: +// +// Currently, :ref:`TLS Inspector ` provides +// application protocol detection based on the requested +// `ALPN `_ values. +// +// 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. +// [#extension: envoy.matching.inputs.application_protocol] +message ApplicationProtocolInput { +} + +// Input that matches by a specific filter state key. +// The value of the provided filter state key will be the raw string representation of the filter state object +// [#extension: envoy.matching.inputs.filter_state] +message FilterStateInput { + string key = 1 [(validate.rules).string = {min_len: 1}]; +} + +// Input that matches dynamic metadata by key. +// DynamicMetadataInput provides a general interface using ``filter`` and ``path`` to retrieve value from +// :ref:`Metadata `. +// +// For example, for the following Metadata: +// +// .. code-block:: yaml +// +// filter_metadata: +// envoy.xxx: +// prop: +// foo: bar +// xyz: +// hello: envoy +// +// The following DynamicMetadataInput will retrieve a string value "bar" from the Metadata. +// +// .. code-block:: yaml +// +// filter: envoy.xxx +// path: +// - key: prop +// - key: foo +// +// [#extension: envoy.matching.inputs.dynamic_metadata] +message DynamicMetadataInput { + // Specifies the segment in a path to retrieve value from Metadata. + // Note: Currently it's not supported to retrieve a value from a list in Metadata. This means that + // if the segment key refers to a list, it has to be the last segment in a path. + message PathSegment { + oneof segment { + option (validate.required) = true; + + // If specified, use the key to retrieve the value in a Struct. + string key = 1 [(validate.rules).string = {min_len: 1}]; + } + } + + // The filter name to retrieve the Struct from the Metadata. + string filter = 1 [(validate.rules).string = {min_len: 1}]; + + // The path to retrieve the Value from the Struct. + repeated PathSegment path = 2 [(validate.rules).repeated = {min_items: 1}]; +} + +// Input that matches by the network namespace of the listener address. +// This input returns the network namespace filepath that was used to create the listening socket. +// On Linux systems, this corresponds to the ``network_namespace_filepath`` field in the +// :ref:`SocketAddress ` configuration. +// +// .. note:: +// +// This input is only meaningful on Linux systems where network namespaces are supported. +// On other platforms, this input will always return an empty value. +// +// [#extension: envoy.matching.inputs.network_namespace] +message NetworkNamespaceInput { +}