diff --git a/CODEOWNERS b/CODEOWNERS index 28a89e8b4671..d7f9c73fb327 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -69,6 +69,8 @@ extensions/filters/common/original_src @klarose @mattklein123 /*/extensions/filters/network/sni_cluster @rshriram @ggreenway # sni_dynamic_forward_proxy extension /*/extensions/filters/network/sni_dynamic_forward_proxy @rshriram @UNOWNED +# sni_to_metadata extension +/*/extensions/filters/network/sni_to_metadata @bplotnick @kbaichoo # tracers.datadog extension /*/extensions/tracers/datadog @dmehala @mattklein123 # tracers.xray extension diff --git a/api/BUILD b/api/BUILD index 8c411195c113..4765dc374118 100644 --- a/api/BUILD +++ b/api/BUILD @@ -254,6 +254,7 @@ proto_library( "//envoy/extensions/filters/network/set_filter_state/v3:pkg", "//envoy/extensions/filters/network/sni_cluster/v3:pkg", "//envoy/extensions/filters/network/sni_dynamic_forward_proxy/v3:pkg", + "//envoy/extensions/filters/network/sni_to_metadata/v3:pkg", "//envoy/extensions/filters/network/tcp_proxy/v3:pkg", "//envoy/extensions/filters/network/thrift_proxy/filters/header_to_metadata/v3:pkg", "//envoy/extensions/filters/network/thrift_proxy/filters/payload_to_metadata/v3:pkg", diff --git a/api/envoy/extensions/filters/network/sni_to_metadata/v3/BUILD b/api/envoy/extensions/filters/network/sni_to_metadata/v3/BUILD new file mode 100644 index 000000000000..bfc486330911 --- /dev/null +++ b/api/envoy/extensions/filters/network/sni_to_metadata/v3/BUILD @@ -0,0 +1,12 @@ +# DO NOT EDIT. This file is generated by tools/proto_format/proto_sync.py. + +load("@envoy_api//bazel:api_build_system.bzl", "api_proto_package") + +licenses(["notice"]) # Apache 2 + +api_proto_package( + deps = [ + "//envoy/type/matcher/v3:pkg", + "@com_github_cncf_xds//udpa/annotations:pkg", + ], +) diff --git a/api/envoy/extensions/filters/network/sni_to_metadata/v3/sni_to_metadata.proto b/api/envoy/extensions/filters/network/sni_to_metadata/v3/sni_to_metadata.proto new file mode 100644 index 000000000000..816b240e062f --- /dev/null +++ b/api/envoy/extensions/filters/network/sni_to_metadata/v3/sni_to_metadata.proto @@ -0,0 +1,53 @@ +syntax = "proto3"; + +package envoy.extensions.filters.network.sni_to_metadata.v3; + +import "envoy/type/matcher/v3/regex.proto"; + +import "udpa/annotations/status.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.filters.network.sni_to_metadata.v3"; +option java_outer_classname = "SniToMetadataProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/sni_to_metadata/v3;sni_to_metadatav3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: SNI to Metadata Filter] + +// Configuration proto schema for ``envoy.extensions.filters.network.sni_to_metadata`` network filter. +// [#extension: envoy.filters.network.sni_to_metadata] +message SniToMetadataFilter { + // MetadataTarget defines where to store extracted metadata. + message MetadataTarget { + // The metadata namespace to use when storing the result. + // If empty, defaults to ``envoy.filters.network.sni_to_metadata`` + string metadata_namespace = 1; + + // The metadata key to use when storing the result. + string metadata_key = 2 [(validate.rules).string = {min_len: 1}]; + + // The metadata value to store. If empty, the entire matched SNI value will be used. + // This field supports capture group substitution using numbered groups from the regex pattern. + // For example: ``app-\\1-\\2`` where ``\\1`` and ``\\2`` refer to the first and second capture groups (note escaped backslashes). + string metadata_value = 3; + } + + // ConnectionRule defines a rule for extracting metadata from SNI. + message ConnectionRule { + // The regex pattern to match against the SNI value. + // Supports Google RE2 numbered capture groups. + // Example: ``^([^.]+)\.([^.]+)\.([^.]+)\.example\.com$`` + // If not specified, the rule will always match and use the entire SNI value. + type.matcher.v3.RegexMatcher pattern = 1; + + // List of metadata targets to populate when this rule matches. + // Each target can use capture groups from the regex pattern in its metadata_value. + // If no pattern is specified, metadata_value will be used as-is or default to the full SNI. + repeated MetadataTarget metadata_targets = 2 [(validate.rules).repeated = {min_items: 1}]; + } + + // List of connection rules to evaluate against the SNI. + // Rules are evaluated in order, and the first matching rule will be applied. + repeated ConnectionRule connection_rules = 1 [(validate.rules).repeated = {min_items: 1}]; +} diff --git a/api/versioning/BUILD b/api/versioning/BUILD index cdad8e287906..4f7d07a85d7d 100644 --- a/api/versioning/BUILD +++ b/api/versioning/BUILD @@ -191,6 +191,7 @@ proto_library( "//envoy/extensions/filters/network/set_filter_state/v3:pkg", "//envoy/extensions/filters/network/sni_cluster/v3:pkg", "//envoy/extensions/filters/network/sni_dynamic_forward_proxy/v3:pkg", + "//envoy/extensions/filters/network/sni_to_metadata/v3:pkg", "//envoy/extensions/filters/network/tcp_proxy/v3:pkg", "//envoy/extensions/filters/network/thrift_proxy/filters/header_to_metadata/v3:pkg", "//envoy/extensions/filters/network/thrift_proxy/filters/payload_to_metadata/v3:pkg", diff --git a/changelogs/current.yaml b/changelogs/current.yaml index cfcdfdb3df3b..c81f2015fd6b 100644 --- a/changelogs/current.yaml +++ b/changelogs/current.yaml @@ -523,5 +523,10 @@ new_features: change: | Added ``max_downstream_connection_duration_jitter_percentage`` to allow adding a jitter to the max downstream connection duration. This can be used to avoid thundering herd problems with many clients being disconnected and possibly reconnecting at the same time. +- area: sni_to_metadata + change: | + Added a new SNI-to-Metadata filter that extracts the SNI of the client connection and stores it in the connection dynamic metadata. + It is able to conditionally extract based on regex patters as well as extract fields and format the metadata using regex capture groups. + See :ref:`SNI-to-Metadata Filter ` for more details. deprecated: diff --git a/docs/root/configuration/listeners/network_filters/network_filters.rst b/docs/root/configuration/listeners/network_filters/network_filters.rst index 1cadbdc33bb6..a772a2583106 100644 --- a/docs/root/configuration/listeners/network_filters/network_filters.rst +++ b/docs/root/configuration/listeners/network_filters/network_filters.rst @@ -33,6 +33,7 @@ filters. set_filter_state sni_cluster_filter sni_dynamic_forward_proxy_filter + sni_to_metadata_filter tcp_proxy_filter thrift_proxy_filter wasm_filter diff --git a/docs/root/configuration/listeners/network_filters/sni_to_metadata_filter.rst b/docs/root/configuration/listeners/network_filters/sni_to_metadata_filter.rst new file mode 100644 index 000000000000..db81eca61293 --- /dev/null +++ b/docs/root/configuration/listeners/network_filters/sni_to_metadata_filter.rst @@ -0,0 +1,28 @@ +.. _config_network_filters_sni_to_metadata: + +SNI-to-Metadata Filter +======================= + +.. attention:: + + SNI-to-Metadata Filter support should be considered alpha and not production ready. + + +The SNI-to-Metadata Filter is a filter that extracts the SNI of the client connection and stores it in the connection dynamic metadata. +It is able to conditionally extract based on regex patters as well as extract fields and format the metadata using regex capture groups. + +Example Configuration +---------------------- + +.. code-block:: yaml + + network_filters: + - name: envoy.filters.network.sni_to_metadata + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.sni_to_metadata.v3.SniToMetadataFilter + connection_rules: + - pattern: ^([^.]+)\.([^.]+)\.([^.]+)\.example\.com$ + metadata_targets: + - metadata_key: app_name + metadata_namespace: envoy.lb + metadata_value: \1 diff --git a/source/extensions/extensions_build_config.bzl b/source/extensions/extensions_build_config.bzl index 5e65a1131d3f..4f9334a71c86 100644 --- a/source/extensions/extensions_build_config.bzl +++ b/source/extensions/extensions_build_config.bzl @@ -236,6 +236,7 @@ EXTENSIONS = { "envoy.filters.network.set_filter_state": "//source/extensions/filters/network/set_filter_state:config", "envoy.filters.network.sni_cluster": "//source/extensions/filters/network/sni_cluster:config", "envoy.filters.network.sni_dynamic_forward_proxy": "//source/extensions/filters/network/sni_dynamic_forward_proxy:config", + "envoy.filters.network.sni_to_metadata": "//source/extensions/filters/network/sni_to_metadata:config", "envoy.filters.network.wasm": "//source/extensions/filters/network/wasm:config", "envoy.filters.network.zookeeper_proxy": "//source/extensions/filters/network/zookeeper_proxy:config", "envoy.filters.network.generic_proxy": "//source/extensions/filters/network/generic_proxy:config", diff --git a/source/extensions/extensions_metadata.yaml b/source/extensions/extensions_metadata.yaml index 302c60991a7b..d0a55df1a6ef 100644 --- a/source/extensions/extensions_metadata.yaml +++ b/source/extensions/extensions_metadata.yaml @@ -804,6 +804,13 @@ envoy.filters.network.sni_dynamic_forward_proxy: status: alpha type_urls: - envoy.extensions.filters.network.sni_dynamic_forward_proxy.v3.FilterConfig +envoy.filters.network.sni_to_metadata: + categories: + - envoy.filters.network + security_posture: unknown + status: alpha + type_urls: + - envoy.extensions.filters.network.sni_to_metadata.v3.SniToMetadataFilter envoy.filters.network.tcp_proxy: categories: - envoy.filters.network diff --git a/source/extensions/filters/network/sni_to_metadata/BUILD b/source/extensions/filters/network/sni_to_metadata/BUILD new file mode 100644 index 000000000000..ab0b1d4ed00a --- /dev/null +++ b/source/extensions/filters/network/sni_to_metadata/BUILD @@ -0,0 +1,39 @@ +load( + "//bazel:envoy_build_system.bzl", + "envoy_cc_extension", + "envoy_cc_library", + "envoy_extension_package", +) + +licenses(["notice"]) # Apache 2 + +envoy_extension_package() + +envoy_cc_extension( + name = "config", + srcs = ["config.cc"], + hdrs = ["config.h"], + deps = [ + ":filter_lib", + "//source/common/common:logger_lib", + "//source/extensions/filters/network:well_known_names", + "//source/extensions/filters/network/common:factory_base_lib", + "@envoy_api//envoy/extensions/filters/network/sni_to_metadata/v3:pkg_cc_proto", + ], +) + +envoy_cc_library( + name = "filter_lib", + srcs = ["filter.cc"], + hdrs = ["filter.h"], + deps = [ + "//envoy/network:connection_interface", + "//envoy/network:filter_interface", + "//source/common/common:logger_lib", + "//source/common/common:regex_lib", + "//source/common/protobuf", + "@com_google_absl//absl/strings", + "@com_googlesource_code_re2//:re2", + "@envoy_api//envoy/extensions/filters/network/sni_to_metadata/v3:pkg_cc_proto", + ], +) diff --git a/source/extensions/filters/network/sni_to_metadata/config.cc b/source/extensions/filters/network/sni_to_metadata/config.cc new file mode 100644 index 000000000000..44c3f0d36d31 --- /dev/null +++ b/source/extensions/filters/network/sni_to_metadata/config.cc @@ -0,0 +1,37 @@ +#include "source/extensions/filters/network/sni_to_metadata/config.h" + +#include "envoy/registry/registry.h" + +#include "source/extensions/filters/network/sni_to_metadata/filter.h" +#include "source/extensions/filters/network/well_known_names.h" + +namespace Envoy { +namespace Extensions { +namespace NetworkFilters { +namespace SniToMetadata { + +SniToMetadataFilterFactory::SniToMetadataFilterFactory() + : Common::ExceptionFreeFactoryBase(NetworkFilterNames::get().SniToMetadata) {} + +absl::StatusOr +SniToMetadataFilterFactory::createFilterFactoryFromProtoTyped( + const FilterConfig& config, Server::Configuration::FactoryContext& context) { + + absl::Status creation_status = absl::OkStatus(); + ConfigSharedPtr filter_config = std::make_shared( + config, context.serverFactoryContext().regexEngine(), creation_status); + + RETURN_IF_NOT_OK_REF(creation_status); + + return [filter_config](Network::FilterManager& filter_manager) -> void { + filter_manager.addReadFilter(std::make_shared(filter_config)); + }; +} + +REGISTER_FACTORY(SniToMetadataFilterFactory, + Server::Configuration::NamedNetworkFilterConfigFactory); + +} // namespace SniToMetadata +} // namespace NetworkFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/network/sni_to_metadata/config.h b/source/extensions/filters/network/sni_to_metadata/config.h new file mode 100644 index 000000000000..6a749761f473 --- /dev/null +++ b/source/extensions/filters/network/sni_to_metadata/config.h @@ -0,0 +1,31 @@ +#pragma once + +#include "envoy/extensions/filters/network/sni_to_metadata/v3/sni_to_metadata.pb.h" +#include "envoy/extensions/filters/network/sni_to_metadata/v3/sni_to_metadata.pb.validate.h" + +#include "source/extensions/filters/network/common/factory_base.h" + +namespace Envoy { +namespace Extensions { +namespace NetworkFilters { +namespace SniToMetadata { + +using FilterConfig = envoy::extensions::filters::network::sni_to_metadata::v3::SniToMetadataFilter; + +/** + * Config registration for the SNI to metadata filter. @see NamedNetworkFilterConfigFactory. + */ +class SniToMetadataFilterFactory : public Common::ExceptionFreeFactoryBase { +public: + SniToMetadataFilterFactory(); + +private: + absl::StatusOr + createFilterFactoryFromProtoTyped(const FilterConfig& config, + Server::Configuration::FactoryContext& context) override; +}; + +} // namespace SniToMetadata +} // namespace NetworkFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/network/sni_to_metadata/filter.cc b/source/extensions/filters/network/sni_to_metadata/filter.cc new file mode 100644 index 000000000000..0b9306cd5bc4 --- /dev/null +++ b/source/extensions/filters/network/sni_to_metadata/filter.cc @@ -0,0 +1,138 @@ +#include "source/extensions/filters/network/sni_to_metadata/filter.h" + +#include + +#include "envoy/common/exception.h" + +#include "source/common/common/regex.h" +#include "source/common/common/utility.h" +#include "source/common/protobuf/protobuf.h" + +#include "absl/status/status.h" +#include "absl/strings/str_cat.h" + +namespace Envoy { +namespace Extensions { +namespace NetworkFilters { +namespace SniToMetadata { + +Config::Config(const FilterConfig& config, Regex::Engine& regex_engine, + absl::Status& creation_status) { + // Compile all connection rules + for (const auto& rule_config : config.connection_rules()) { + CompiledConnectionRule compiled_rule; + + // Compile the regex pattern if one is specified + if (rule_config.has_pattern()) { + auto regex_result = Regex::Utility::parseRegex(rule_config.pattern(), regex_engine); + SET_AND_RETURN_IF_NOT_OK(regex_result.status(), creation_status); + compiled_rule.regex_matcher = std::move(regex_result.value()); + } + // If no pattern is specified, regex_matcher remains nullptr + + for (const auto& target : rule_config.metadata_targets()) { + compiled_rule.metadata_targets.push_back(target); + } + + compiled_rules_.push_back(std::move(compiled_rule)); + } +} + +Filter::Filter(ConfigSharedPtr config) : config_(std::move(config)), initialized_(false) {} + +Network::FilterStatus Filter::onData(Buffer::Instance&, bool) { + // Only process once per connection + if (initialized_) { + return Network::FilterStatus::Continue; + } + + initialized_ = true; + + absl::string_view sni = callbacks_->connection().requestedServerName(); + + if (sni.empty()) { + ENVOY_CONN_LOG(trace, "sni_to_metadata: no SNI found", callbacks_->connection()); + return Network::FilterStatus::Continue; + } + + ENVOY_CONN_LOG(trace, "sni_to_metadata: processing SNI {}", callbacks_->connection(), sni); + + bool any_rule_matched = processConnectionRules(sni); + + if (!any_rule_matched) { + ENVOY_CONN_LOG(debug, "sni_to_metadata: no rules matched SNI {}", callbacks_->connection(), + sni); + } + + return Network::FilterStatus::Continue; +} + +bool Filter::processConnectionRules(absl::string_view sni) { + bool any_matched = false; + + // Evaluate rules in order, stop at first match + for (const auto& rule : config_->compiledRules()) { + if (applyConnectionRule(rule, sni)) { + any_matched = true; + break; + } + } + + return any_matched; +} + +bool Filter::applyConnectionRule(const CompiledConnectionRule& rule, absl::string_view sni) { + // Check if rule matches + if (rule.regex_matcher != nullptr) { + // Rule has a regex pattern - check if it matches + if (!rule.regex_matcher->match(sni)) { + return false; + } + ENVOY_CONN_LOG(trace, "sni_to_metadata: regex rule matched SNI {}", callbacks_->connection(), + sni); + } else { + // Rule has no pattern - always matches + ENVOY_CONN_LOG(trace, "sni_to_metadata: pattern-less rule applied to SNI {}", + callbacks_->connection(), sni); + } + + // Apply metadata targets for this rule + bool metadata_applied = false; + for (const auto& target : rule.metadata_targets) { + std::string metadata_value; + + if (target.metadata_value().empty()) { + // Use the full SNI if no specific value is configured + metadata_value = std::string(sni); + } else if (rule.regex_matcher != nullptr) { + // Apply regex substitution using capture groups + metadata_value = rule.regex_matcher->replaceAll(sni, target.metadata_value()); + } else { + // No regex pattern - use metadata_value as-is (no substitution) + metadata_value = target.metadata_value(); + } + + if (!metadata_value.empty()) { + absl::string_view effective_namespace = target.metadata_namespace().empty() + ? DEFAULT_METADATA_NAMESPACE + : target.metadata_namespace(); + + // Store in dynamic metadata + Protobuf::Struct& metadata = (*callbacks_->connection() + .streamInfo() + .dynamicMetadata() + .mutable_filter_metadata())[effective_namespace]; + (*metadata.mutable_fields())[target.metadata_key()].set_string_value( + std::move(metadata_value)); + + metadata_applied = true; + } + } + + return metadata_applied; +} + +} // namespace SniToMetadata +} // namespace NetworkFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/network/sni_to_metadata/filter.h b/source/extensions/filters/network/sni_to_metadata/filter.h new file mode 100644 index 000000000000..fb644d884ddf --- /dev/null +++ b/source/extensions/filters/network/sni_to_metadata/filter.h @@ -0,0 +1,89 @@ +#pragma once + +#include +#include + +#include "envoy/buffer/buffer.h" +#include "envoy/extensions/filters/network/sni_to_metadata/v3/sni_to_metadata.pb.h" +#include "envoy/network/filter.h" + +#include "source/common/common/logger.h" +#include "source/common/common/regex.h" + +#include "absl/strings/string_view.h" + +namespace Envoy { +namespace Extensions { +namespace NetworkFilters { +namespace SniToMetadata { + +using FilterConfig = envoy::extensions::filters::network::sni_to_metadata::v3::SniToMetadataFilter; + +/** + * Represents a compiled connection rule with optional regex matcher and metadata targets. + */ +struct CompiledConnectionRule { + // Compiled regex pattern for SNI matching (nullptr if no pattern specified) + Regex::CompiledMatcherPtr regex_matcher; + + // List of metadata targets to populate when this rule matches + std::vector metadata_targets; +}; + +/** + * Configuration for the SniToMetadata filter. + */ +class Config { +public: + Config(const FilterConfig& config, Regex::Engine& regex_engine, absl::Status& creation_status); + + const std::vector& compiledRules() const { return compiled_rules_; } + +private: + // Compiled connection rules ready for processing + std::vector compiled_rules_; +}; + +using ConfigSharedPtr = std::shared_ptr; + +/** + * A network filter that extracts SNI from the connection, applies regex patterns with capture + * groups, and stores the results into dynamic metadata according to the configured rules. + */ +class Filter : public Network::ReadFilter, Logger::Loggable { +public: + Filter(ConfigSharedPtr config); + + // Network::ReadFilter + void initializeReadFilterCallbacks(Network::ReadFilterCallbacks& callbacks) override { + callbacks_ = &callbacks; + } + Network::FilterStatus onData(Buffer::Instance&, bool) override; + Network::FilterStatus onNewConnection() override { return Network::FilterStatus::Continue; } + +private: + /** + * Process SNI against all connection rules and apply matching metadata. + * Returns true if any rule matched and metadata was applied. + */ + bool processConnectionRules(absl::string_view sni); + + /** + * Apply a specific connection rule to the SNI value. + * Returns true if the rule matched and metadata was applied. + */ + bool applyConnectionRule(const CompiledConnectionRule& rule, absl::string_view sni); + + ConfigSharedPtr config_; + Network::ReadFilterCallbacks* callbacks_; + bool initialized_ : 1; + + // Default metadata namespace + static constexpr absl::string_view DEFAULT_METADATA_NAMESPACE = + "envoy.filters.network.sni_to_metadata"; +}; + +} // namespace SniToMetadata +} // namespace NetworkFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/network/well_known_names.h b/source/extensions/filters/network/well_known_names.h index 45483e7a66d8..ec73d126b92b 100644 --- a/source/extensions/filters/network/well_known_names.h +++ b/source/extensions/filters/network/well_known_names.h @@ -55,6 +55,8 @@ class NetworkFilterNameValues { const std::string SniCluster = "envoy.filters.network.sni_cluster"; // SNI Dynamic forward proxy filter const std::string SniDynamicForwardProxy = "envoy.filters.network.sni_dynamic_forward_proxy"; + // SNI to metadata filter + const std::string SniToMetadata = "envoy.filters.network.sni_to_metadata"; // ZooKeeper proxy filter const std::string ZooKeeperProxy = "envoy.filters.network.zookeeper_proxy"; // WebAssembly filter diff --git a/test/extensions/filters/network/sni_to_metadata/BUILD b/test/extensions/filters/network/sni_to_metadata/BUILD new file mode 100644 index 000000000000..204151e9fa0f --- /dev/null +++ b/test/extensions/filters/network/sni_to_metadata/BUILD @@ -0,0 +1,37 @@ +load( + "//bazel:envoy_build_system.bzl", + "envoy_package", +) +load( + "//test/extensions:extensions_build_system.bzl", + "envoy_extension_cc_test", +) + +licenses(["notice"]) # Apache 2 + +envoy_package() + +envoy_extension_cc_test( + name = "filter_test", + srcs = ["filter_test.cc"], + extension_names = ["envoy.filters.network.sni_to_metadata"], + rbe_pool = "6gig", + deps = [ + "//source/extensions/filters/network/sni_to_metadata:filter_lib", + "//test/mocks/network:network_mocks", + "//test/mocks/server:factory_context_mocks", + "//test/mocks/stream_info:stream_info_mocks", + ], +) + +envoy_extension_cc_test( + name = "config_test", + srcs = ["config_test.cc"], + extension_names = ["envoy.filters.network.sni_to_metadata"], + rbe_pool = "6gig", + deps = [ + "//source/extensions/filters/network/sni_to_metadata:config", + "//test/mocks/network:network_mocks", + "//test/mocks/server:factory_context_mocks", + ], +) diff --git a/test/extensions/filters/network/sni_to_metadata/config_test.cc b/test/extensions/filters/network/sni_to_metadata/config_test.cc new file mode 100644 index 000000000000..210baf4478ea --- /dev/null +++ b/test/extensions/filters/network/sni_to_metadata/config_test.cc @@ -0,0 +1,170 @@ +#include "source/extensions/filters/network/sni_to_metadata/config.h" + +#include "test/mocks/network/mocks.h" +#include "test/mocks/server/factory_context.h" + +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +using testing::_; +using testing::NiceMock; + +namespace Envoy { +namespace Extensions { +namespace NetworkFilters { +namespace SniToMetadata { + +// Test that the factory creates a filter config properly +TEST(SniToMetadataFilterConfigTest, ValidConfigProto) { + NiceMock context; + SniToMetadataFilterFactory factory; + + // Create a valid configuration + envoy::extensions::filters::network::sni_to_metadata::v3::SniToMetadataFilter config; + auto* rule = config.add_connection_rules(); + auto* pattern = rule->mutable_pattern(); + pattern->mutable_google_re2(); + pattern->set_regex(R"(^([^.]+)\.([^.]+)\.([^.]+)\.example\.com$)"); + + auto* target = rule->add_metadata_targets(); + target->set_metadata_key("policy_name"); + target->set_metadata_namespace("envoy.filters.network.sni_to_metadata"); + target->set_metadata_value("app-\\1"); + + // Use the public interface to create filter factory + auto cb_result = factory.createFilterFactoryFromProto(config, context); + EXPECT_TRUE(cb_result.ok()); + Network::FilterFactoryCb cb = cb_result.value(); + EXPECT_TRUE(cb); + + // Test that the callback creates a filter when called + NiceMock filter_manager; + EXPECT_CALL(filter_manager, addReadFilter(_)); + cb(filter_manager); +} + +// Test config validation - invalid regex should fail +TEST(SniToMetadataFilterConfigTest, InvalidRegexFails) { + NiceMock context; + SniToMetadataFilterFactory factory; + + envoy::extensions::filters::network::sni_to_metadata::v3::SniToMetadataFilter config; + auto* rule = config.add_connection_rules(); + auto* pattern = rule->mutable_pattern(); + pattern->mutable_google_re2(); + pattern->set_regex("[invalid regex pattern"); // Missing closing bracket + + auto* target = rule->add_metadata_targets(); + target->set_metadata_key("test_key"); + target->set_metadata_namespace("test.namespace"); + target->set_metadata_value("\\1"); + + // This should fail to create the config + auto result = factory.createFilterFactoryFromProto(config, context); + EXPECT_FALSE(result.ok()); +} + +// Test valid config with multiple rules and targets +TEST(SniToMetadataFilterConfigTest, ValidComplexConfig) { + NiceMock context; + SniToMetadataFilterFactory factory; + + envoy::extensions::filters::network::sni_to_metadata::v3::SniToMetadataFilter config; + + // First rule + auto* rule1 = config.add_connection_rules(); + auto* pattern1 = rule1->mutable_pattern(); + pattern1->mutable_google_re2(); + pattern1->set_regex(R"(^([^.]+)\.([^.]+)\.([^.]+)\.example\.com$)"); + + auto* target1 = rule1->add_metadata_targets(); + target1->set_metadata_key("app_name"); + target1->set_metadata_namespace("custom.metadata"); + target1->set_metadata_value("\\1"); + + auto* target2 = rule1->add_metadata_targets(); + target2->set_metadata_key("region"); + target2->set_metadata_namespace("custom.metadata"); + target2->set_metadata_value("\\2"); + + // Second rule + auto* rule2 = config.add_connection_rules(); + auto* pattern2 = rule2->mutable_pattern(); + pattern2->mutable_google_re2(); + pattern2->set_regex(R"(^([^.]+)\.service\.([^.]+)\.com$)"); + + auto* target3 = rule2->add_metadata_targets(); + target3->set_metadata_key("service_name"); + target3->set_metadata_namespace("another.namespace"); + target3->set_metadata_value("\\1"); + + // This should succeed + auto cb_result = factory.createFilterFactoryFromProto(config, context); + EXPECT_TRUE(cb_result.ok()); + Network::FilterFactoryCb cb = cb_result.value(); + EXPECT_TRUE(cb); + + // Test that the callback creates a filter when called + NiceMock filter_manager; + EXPECT_CALL(filter_manager, addReadFilter(_)); + cb(filter_manager); +} + +// Test default namespace behavior +TEST(SniToMetadataFilterConfigTest, DefaultNamespaceBehavior) { + NiceMock context; + SniToMetadataFilterFactory factory; + + envoy::extensions::filters::network::sni_to_metadata::v3::SniToMetadataFilter config; + auto* rule = config.add_connection_rules(); + auto* pattern = rule->mutable_pattern(); + pattern->mutable_google_re2(); + pattern->set_regex(R"(^([^.]+)\..*$)"); + + auto* target = rule->add_metadata_targets(); + target->set_metadata_key("test_key"); + // metadata_namespace is intentionally left empty to test default behavior + target->set_metadata_value("\\1"); + + // This should succeed - empty namespace should use default + auto cb_result = factory.createFilterFactoryFromProto(config, context); + EXPECT_TRUE(cb_result.ok()); + Network::FilterFactoryCb cb = cb_result.value(); + EXPECT_TRUE(cb); + + // Test that the callback creates a filter when called + NiceMock filter_manager; + EXPECT_CALL(filter_manager, addReadFilter(_)); + cb(filter_manager); +} + +// Test valid config without pattern +TEST(SniToMetadataFilterConfigTest, ValidConfigWithoutPattern) { + NiceMock context; + SniToMetadataFilterFactory factory; + + envoy::extensions::filters::network::sni_to_metadata::v3::SniToMetadataFilter config; + auto* rule = config.add_connection_rules(); + // No pattern specified - should always match + + auto* target = rule->add_metadata_targets(); + target->set_metadata_key("full_sni"); + target->set_metadata_namespace("test.namespace"); + // metadata_value empty - should use full SNI + + // This should succeed + auto cb_result = factory.createFilterFactoryFromProto(config, context); + EXPECT_TRUE(cb_result.ok()); + Network::FilterFactoryCb cb = cb_result.value(); + EXPECT_TRUE(cb); + + // Test that the callback creates a filter when called + NiceMock filter_manager; + EXPECT_CALL(filter_manager, addReadFilter(_)); + cb(filter_manager); +} + +} // namespace SniToMetadata +} // namespace NetworkFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/network/sni_to_metadata/filter_test.cc b/test/extensions/filters/network/sni_to_metadata/filter_test.cc new file mode 100644 index 000000000000..4a6fb482aa7a --- /dev/null +++ b/test/extensions/filters/network/sni_to_metadata/filter_test.cc @@ -0,0 +1,517 @@ +#include "source/extensions/filters/network/sni_to_metadata/filter.h" + +#include "test/mocks/network/mocks.h" +#include "test/mocks/server/factory_context.h" +#include "test/mocks/stream_info/mocks.h" + +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +using testing::_; +using testing::NiceMock; +using testing::Return; +using testing::ReturnRef; + +namespace Envoy { +namespace Extensions { +namespace NetworkFilters { +namespace SniToMetadata { + +class SniToMetadataFilterTest : public testing::Test { +public: + void setUpFilter(const FilterConfig& config) { + absl::Status creation_status = absl::OkStatus(); + auto config_shared = std::make_shared( + config, context_.serverFactoryContext().regexEngine(), creation_status); + EXPECT_TRUE(creation_status.ok()); + filter_ = std::make_unique(config_shared); + filter_->initializeReadFilterCallbacks(filter_callbacks_); + + ON_CALL(filter_callbacks_, connection()).WillByDefault(ReturnRef(connection_)); + ON_CALL(connection_, streamInfo()).WillByDefault(ReturnRef(stream_info_)); + ON_CALL(Const(connection_), streamInfo()).WillByDefault(ReturnRef(stream_info_)); + ON_CALL(stream_info_, dynamicMetadata()).WillByDefault(ReturnRef(dynamic_metadata_)); + } + + // Helper to create a basic config with a single rule + FilterConfig createBasicConfig() { + FilterConfig config; + + auto* rule = config.add_connection_rules(); + auto* pattern = rule->mutable_pattern(); + pattern->mutable_google_re2(); + pattern->set_regex(R"(^([^.]+)\.([^.]+)\.([^.]+)\.example\.com$)"); + + auto* target = rule->add_metadata_targets(); + target->set_metadata_key("shard_id"); + target->set_metadata_namespace("envoy.filters.network.sni_to_metadata"); + target->set_metadata_value("shard-\\1-\\2"); + + return config; + } + +protected: + NiceMock context_; + std::unique_ptr filter_; + NiceMock filter_callbacks_; + NiceMock connection_; + NiceMock stream_info_; + envoy::config::core::v3::Metadata dynamic_metadata_; +}; + +// Test successful SNI extraction and metadata setting with capture groups +TEST_F(SniToMetadataFilterTest, SuccessfulSniExtractionWithCaptureGroups) { + auto config = createBasicConfig(); + setUpFilter(config); + + // Mock SNI from requestedServerName + ON_CALL(connection_, requestedServerName()) + .WillByDefault(Return("myapp.us-west-2.prod.example.com")); + + Buffer::OwnedImpl buffer("test"); + + Network::FilterStatus status = filter_->onData(buffer, false); + EXPECT_EQ(Network::FilterStatus::Continue, status); + + // Verify the metadata was set correctly with capture group substitution + const auto& filter_metadata = dynamic_metadata_.filter_metadata(); + ASSERT_TRUE(filter_metadata.contains("envoy.filters.network.sni_to_metadata")); + + const auto& metadata = filter_metadata.at("envoy.filters.network.sni_to_metadata"); + ASSERT_TRUE(metadata.fields().contains("shard_id")); + EXPECT_EQ(metadata.fields().at("shard_id").string_value(), "shard-myapp-us-west-2"); +} + +// Test multiple metadata targets with different capture groups +TEST_F(SniToMetadataFilterTest, MultipleCaptureGroupsInDifferentTargets) { + FilterConfig config; + + auto* rule = config.add_connection_rules(); + auto* pattern = rule->mutable_pattern(); + pattern->mutable_google_re2(); + pattern->set_regex(R"(^([^.]+)\.([^.]+)\.([^.]+)\.example\.com$)"); + + // First target: app name + auto* target1 = rule->add_metadata_targets(); + target1->set_metadata_key("app_name"); + target1->set_metadata_namespace("test.namespace"); + target1->set_metadata_value("\\1"); + + // Second target: region + auto* target2 = rule->add_metadata_targets(); + target2->set_metadata_key("region"); + target2->set_metadata_namespace("test.namespace"); + target2->set_metadata_value("\\2"); + + // Third target: environment + auto* target3 = rule->add_metadata_targets(); + target3->set_metadata_key("environment"); + target3->set_metadata_namespace("test.namespace"); + target3->set_metadata_value("\\3"); + + // Fourth target: combined value + auto* target4 = rule->add_metadata_targets(); + target4->set_metadata_key("combined"); + target4->set_metadata_namespace("test.namespace"); + target4->set_metadata_value("\\1-in-\\2-\\3"); + + setUpFilter(config); + + ON_CALL(connection_, requestedServerName()) + .WillByDefault(Return("webapp.us-east-1.test.example.com")); + + Buffer::OwnedImpl buffer("test"); + + Network::FilterStatus status = filter_->onData(buffer, false); + EXPECT_EQ(Network::FilterStatus::Continue, status); + + // Verify all metadata was set correctly + const auto& filter_metadata = dynamic_metadata_.filter_metadata(); + ASSERT_TRUE(filter_metadata.contains("test.namespace")); + + const auto& metadata = filter_metadata.at("test.namespace"); + ASSERT_TRUE(metadata.fields().contains("app_name")); + ASSERT_TRUE(metadata.fields().contains("region")); + ASSERT_TRUE(metadata.fields().contains("environment")); + ASSERT_TRUE(metadata.fields().contains("combined")); + + EXPECT_EQ(metadata.fields().at("app_name").string_value(), "webapp"); + EXPECT_EQ(metadata.fields().at("region").string_value(), "us-east-1"); + EXPECT_EQ(metadata.fields().at("environment").string_value(), "test"); + EXPECT_EQ(metadata.fields().at("combined").string_value(), "webapp-in-us-east-1-test"); +} + +// Test no SNI available +TEST_F(SniToMetadataFilterTest, NoSniAvailable) { + auto config = createBasicConfig(); + setUpFilter(config); + + // Mock empty SNI + ON_CALL(connection_, requestedServerName()).WillByDefault(Return("")); + + Buffer::OwnedImpl buffer("test"); + + Network::FilterStatus status = filter_->onData(buffer, false); + EXPECT_EQ(Network::FilterStatus::Continue, status); + + // Verify no metadata was set + const auto& filter_metadata = dynamic_metadata_.filter_metadata(); + EXPECT_FALSE(filter_metadata.contains("envoy.filters.network.sni_to_metadata")); +} + +// Test SNI that doesn't match pattern +TEST_F(SniToMetadataFilterTest, SniDoesNotMatchPattern) { + auto config = createBasicConfig(); + setUpFilter(config); + + // Mock SNI that doesn't match the expected pattern + ON_CALL(connection_, requestedServerName()).WillByDefault(Return("invalid.pattern.com")); + + Buffer::OwnedImpl buffer("test"); + + Network::FilterStatus status = filter_->onData(buffer, false); + EXPECT_EQ(Network::FilterStatus::Continue, status); + + // Verify no metadata was set + const auto& filter_metadata = dynamic_metadata_.filter_metadata(); + EXPECT_FALSE(filter_metadata.contains("envoy.filters.network.sni_to_metadata")); +} + +// Test multiple rules with first matching rule applied +TEST_F(SniToMetadataFilterTest, MultipleRulesFirstMatchWins) { + FilterConfig config; + + // First rule: matches example.com pattern + auto* rule1 = config.add_connection_rules(); + auto* pattern1 = rule1->mutable_pattern(); + pattern1->mutable_google_re2(); + pattern1->set_regex(R"(^([^.]+)\.([^.]+)\.([^.]+)\.example\.com$)"); + + auto* target1 = rule1->add_metadata_targets(); + target1->set_metadata_key("source"); + target1->set_metadata_namespace("test.namespace"); + target1->set_metadata_value("example"); + + // Second rule: matches any pattern with 3 parts + auto* rule2 = config.add_connection_rules(); + auto* pattern2 = rule2->mutable_pattern(); + pattern2->mutable_google_re2(); + pattern2->set_regex(R"(^([^.]+)\.([^.]+)\.([^.]+)$)"); + + auto* target2 = rule2->add_metadata_targets(); + target2->set_metadata_key("source"); + target2->set_metadata_namespace("test.namespace"); + target2->set_metadata_value("generic"); + + setUpFilter(config); + + ON_CALL(connection_, requestedServerName()) + .WillByDefault(Return("myapp.us-west-2.prod.example.com")); + + Buffer::OwnedImpl buffer("test"); + + Network::FilterStatus status = filter_->onData(buffer, false); + EXPECT_EQ(Network::FilterStatus::Continue, status); + + // Verify only the first rule's metadata was applied + const auto& filter_metadata = dynamic_metadata_.filter_metadata(); + ASSERT_TRUE(filter_metadata.contains("test.namespace")); + + const auto& metadata = filter_metadata.at("test.namespace"); + ASSERT_TRUE(metadata.fields().contains("source")); + EXPECT_EQ(metadata.fields().at("source").string_value(), "example"); +} + +// Test metadata target without metadata_value uses full SNI +TEST_F(SniToMetadataFilterTest, NoMetadataValueUsesFullSni) { + FilterConfig config; + + auto* rule = config.add_connection_rules(); + auto* pattern = rule->mutable_pattern(); + pattern->mutable_google_re2(); + pattern->set_regex(R"(^([^.]+)\.([^.]+)\.([^.]+)\.example\.com$)"); + + auto* target = rule->add_metadata_targets(); + target->set_metadata_key("full_sni"); + target->set_metadata_namespace("test.namespace"); + // metadata_value is intentionally left empty + + setUpFilter(config); + + std::string test_sni = "myapp.us-west-2.prod.example.com"; + ON_CALL(connection_, requestedServerName()).WillByDefault(Return(test_sni)); + + Buffer::OwnedImpl buffer("test"); + + Network::FilterStatus status = filter_->onData(buffer, false); + EXPECT_EQ(Network::FilterStatus::Continue, status); + + // Verify the full SNI was used as metadata value + const auto& filter_metadata = dynamic_metadata_.filter_metadata(); + ASSERT_TRUE(filter_metadata.contains("test.namespace")); + + const auto& metadata = filter_metadata.at("test.namespace"); + ASSERT_TRUE(metadata.fields().contains("full_sni")); + EXPECT_EQ(metadata.fields().at("full_sni").string_value(), test_sni); +} + +// Test default metadata namespace when not specified +TEST_F(SniToMetadataFilterTest, DefaultMetadataNamespace) { + FilterConfig config; + + auto* rule = config.add_connection_rules(); + auto* pattern = rule->mutable_pattern(); + pattern->mutable_google_re2(); + pattern->set_regex(R"(^([^.]+)\.([^.]+)\.([^.]+)\.example\.com$)"); + + auto* target = rule->add_metadata_targets(); + target->set_metadata_key("app_name"); + // metadata_namespace is intentionally left empty to test default + target->set_metadata_value("\\1"); + + setUpFilter(config); + + ON_CALL(connection_, requestedServerName()) + .WillByDefault(Return("testapp.region.env.example.com")); + + Buffer::OwnedImpl buffer("test"); + + Network::FilterStatus status = filter_->onData(buffer, false); + EXPECT_EQ(Network::FilterStatus::Continue, status); + + // Verify the default namespace was used + const auto& filter_metadata = dynamic_metadata_.filter_metadata(); + ASSERT_TRUE(filter_metadata.contains("envoy.filters.network.sni_to_metadata")); + + const auto& metadata = filter_metadata.at("envoy.filters.network.sni_to_metadata"); + ASSERT_TRUE(metadata.fields().contains("app_name")); + EXPECT_EQ(metadata.fields().at("app_name").string_value(), "testapp"); +} + +// Test that filter only processes once per connection +TEST_F(SniToMetadataFilterTest, OnlyProcessOncePerConnection) { + auto config = createBasicConfig(); + setUpFilter(config); + + ON_CALL(connection_, requestedServerName()) + .WillByDefault(Return("myapp.us-west-2.prod.example.com")); + + Buffer::OwnedImpl buffer1("test1"); + Buffer::OwnedImpl buffer2("test2"); + + // First call should process + Network::FilterStatus status1 = filter_->onData(buffer1, false); + EXPECT_EQ(Network::FilterStatus::Continue, status1); + + // Verify metadata was set + const auto& filter_metadata = dynamic_metadata_.filter_metadata(); + ASSERT_TRUE(filter_metadata.contains("envoy.filters.network.sni_to_metadata")); + + const auto& metadata = filter_metadata.at("envoy.filters.network.sni_to_metadata"); + ASSERT_TRUE(metadata.fields().contains("shard_id")); + EXPECT_EQ(metadata.fields().at("shard_id").string_value(), "shard-myapp-us-west-2"); + + // Clear the metadata to verify second call doesn't set it again + dynamic_metadata_.clear_filter_metadata(); + + // Second call should skip processing + Network::FilterStatus status2 = filter_->onData(buffer2, false); + EXPECT_EQ(Network::FilterStatus::Continue, status2); + + // Verify no metadata was set on second call + const auto& filter_metadata2 = dynamic_metadata_.filter_metadata(); + EXPECT_FALSE(filter_metadata2.contains("envoy.filters.network.sni_to_metadata")); +} + +// Test complex regex patterns +TEST_F(SniToMetadataFilterTest, ComplexRegexPatterns) { + FilterConfig config; + + auto* rule = config.add_connection_rules(); + auto* pattern = rule->mutable_pattern(); + pattern->mutable_google_re2(); + // More complex pattern that captures service, version, region, and environment + pattern->set_regex(R"(^([^.]+)-v(\d+)\.([^.]+)\.([^.]+)\.service\.company\.com$)"); + + auto* target1 = rule->add_metadata_targets(); + target1->set_metadata_key("service_name"); + target1->set_metadata_namespace("company.metadata"); + target1->set_metadata_value("\\1"); + + auto* target2 = rule->add_metadata_targets(); + target2->set_metadata_key("service_version"); + target2->set_metadata_namespace("company.metadata"); + target2->set_metadata_value("v\\2"); + + auto* target3 = rule->add_metadata_targets(); + target3->set_metadata_key("deployment"); + target3->set_metadata_namespace("company.metadata"); + target3->set_metadata_value("\\1-v\\2-\\3-\\4"); + + setUpFilter(config); + + ON_CALL(connection_, requestedServerName()) + .WillByDefault(Return("user-service-v42.us-west-2.production.service.company.com")); + + Buffer::OwnedImpl buffer("test"); + + Network::FilterStatus status = filter_->onData(buffer, false); + EXPECT_EQ(Network::FilterStatus::Continue, status); + + // Verify all metadata was set correctly + const auto& filter_metadata = dynamic_metadata_.filter_metadata(); + ASSERT_TRUE(filter_metadata.contains("company.metadata")); + + const auto& metadata = filter_metadata.at("company.metadata"); + ASSERT_TRUE(metadata.fields().contains("service_name")); + ASSERT_TRUE(metadata.fields().contains("service_version")); + ASSERT_TRUE(metadata.fields().contains("deployment")); + + EXPECT_EQ(metadata.fields().at("service_name").string_value(), "user-service"); + EXPECT_EQ(metadata.fields().at("service_version").string_value(), "v42"); + EXPECT_EQ(metadata.fields().at("deployment").string_value(), + "user-service-v42-us-west-2-production"); +} + +// Test rule without pattern captures full SNI +TEST_F(SniToMetadataFilterTest, NoPatternCapturesFullSni) { + FilterConfig config; + + auto* rule = config.add_connection_rules(); + // No pattern specified - should always match + + auto* target = rule->add_metadata_targets(); + target->set_metadata_key("full_sni"); + target->set_metadata_namespace("test.namespace"); + // metadata_value is intentionally left empty to use full SNI + + setUpFilter(config); + + std::string test_sni = "example.com"; + ON_CALL(connection_, requestedServerName()).WillByDefault(Return(test_sni)); + + Buffer::OwnedImpl buffer("test"); + + Network::FilterStatus status = filter_->onData(buffer, false); + EXPECT_EQ(Network::FilterStatus::Continue, status); + + // Verify the full SNI was used as metadata value + const auto& filter_metadata = dynamic_metadata_.filter_metadata(); + ASSERT_TRUE(filter_metadata.contains("test.namespace")); + + const auto& metadata = filter_metadata.at("test.namespace"); + ASSERT_TRUE(metadata.fields().contains("full_sni")); + EXPECT_EQ(metadata.fields().at("full_sni").string_value(), test_sni); +} + +// Test rule without pattern uses static metadata value +TEST_F(SniToMetadataFilterTest, NoPatternWithStaticValue) { + FilterConfig config; + + auto* rule = config.add_connection_rules(); + // No pattern specified - should always match + + auto* target1 = rule->add_metadata_targets(); + target1->set_metadata_key("service_type"); + target1->set_metadata_namespace("test.namespace"); + target1->set_metadata_value("web-service"); // Static value + + auto* target2 = rule->add_metadata_targets(); + target2->set_metadata_key("original_sni"); + target2->set_metadata_namespace("test.namespace"); + // metadata_value empty - should use full SNI + + setUpFilter(config); + + std::string test_sni = "api.example.com"; + ON_CALL(connection_, requestedServerName()).WillByDefault(Return(test_sni)); + + Buffer::OwnedImpl buffer("test"); + + Network::FilterStatus status = filter_->onData(buffer, false); + EXPECT_EQ(Network::FilterStatus::Continue, status); + + // Verify both metadata values were set correctly + const auto& filter_metadata = dynamic_metadata_.filter_metadata(); + ASSERT_TRUE(filter_metadata.contains("test.namespace")); + + const auto& metadata = filter_metadata.at("test.namespace"); + ASSERT_TRUE(metadata.fields().contains("service_type")); + ASSERT_TRUE(metadata.fields().contains("original_sni")); + + EXPECT_EQ(metadata.fields().at("service_type").string_value(), "web-service"); + EXPECT_EQ(metadata.fields().at("original_sni").string_value(), test_sni); +} + +// Test mixed rules: one with pattern, one without +TEST_F(SniToMetadataFilterTest, MixedRulesPatternAndNoPattern) { + FilterConfig config; + + // First rule: no pattern (should always match, but we want it to not match this SNI) + auto* rule1 = config.add_connection_rules(); + auto* pattern1 = rule1->mutable_pattern(); + pattern1->mutable_google_re2(); + pattern1->set_regex(R"(^api\.(.+)$)"); // Only matches SNIs starting with "api." + + auto* target1 = rule1->add_metadata_targets(); + target1->set_metadata_key("api_domain"); + target1->set_metadata_namespace("test.namespace"); + target1->set_metadata_value("\\1"); + + // Second rule: no pattern (fallback - should match anything) + auto* rule2 = config.add_connection_rules(); + // No pattern specified + + auto* target2 = rule2->add_metadata_targets(); + target2->set_metadata_key("fallback"); + target2->set_metadata_namespace("test.namespace"); + target2->set_metadata_value("default-service"); + + setUpFilter(config); + + // Test with SNI that doesn't match first rule + std::string test_sni = "webapp.example.com"; + ON_CALL(connection_, requestedServerName()).WillByDefault(Return(test_sni)); + + Buffer::OwnedImpl buffer("test"); + + Network::FilterStatus status = filter_->onData(buffer, false); + EXPECT_EQ(Network::FilterStatus::Continue, status); + + // Verify only the fallback rule was applied (first matching rule wins) + const auto& filter_metadata = dynamic_metadata_.filter_metadata(); + ASSERT_TRUE(filter_metadata.contains("test.namespace")); + + const auto& metadata = filter_metadata.at("test.namespace"); + EXPECT_FALSE(metadata.fields().contains("api_domain")); // First rule didn't match + ASSERT_TRUE(metadata.fields().contains("fallback")); // Second rule matched + + EXPECT_EQ(metadata.fields().at("fallback").string_value(), "default-service"); +} + +// Test invalid regex pattern returns error +TEST_F(SniToMetadataFilterTest, InvalidRegexReturnsError) { + NiceMock context; + + FilterConfig config; + auto* rule = config.add_connection_rules(); + auto* pattern = rule->mutable_pattern(); + pattern->mutable_google_re2(); + pattern->set_regex("[invalid regex pattern"); // Missing closing bracket + + auto* target = rule->add_metadata_targets(); + target->set_metadata_key("test_key"); + target->set_metadata_namespace("test.namespace"); + target->set_metadata_value("\\1"); + + // This should fail to create the config + absl::Status creation_status = absl::OkStatus(); + auto config_shared = std::make_shared( + config, context.serverFactoryContext().regexEngine(), creation_status); + EXPECT_FALSE(creation_status.ok()); +} + +} // namespace SniToMetadata +} // namespace NetworkFilters +} // namespace Extensions +} // namespace Envoy