diff --git a/api/envoy/extensions/filters/network/reverse_tunnel/v3/reverse_tunnel.proto b/api/envoy/extensions/filters/network/reverse_tunnel/v3/reverse_tunnel.proto index fbb5bd14e30e1..28bcfe2c93e33 100644 --- a/api/envoy/extensions/filters/network/reverse_tunnel/v3/reverse_tunnel.proto +++ b/api/envoy/extensions/filters/network/reverse_tunnel/v3/reverse_tunnel.proto @@ -19,27 +19,98 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE; // Reverse Tunnel Network Filter :ref:`configuration overview `. // [#extension: envoy.filters.network.reverse_tunnel] +// Validation configuration for reverse tunnel identifiers. +// Validates the node ID and cluster ID extracted from reverse tunnel handshake headers +// against expected values specified using format strings. +message Validation { + // Format string to extract the expected node identifier for validation. + // The formatted value is compared against the ``x-envoy-reverse-tunnel-node-id`` header + // from the incoming handshake request. If they do not match, the connection is rejected + // with HTTP ``403 Forbidden``. + // + // Supports Envoy's :ref:`command operators `: + // + // * ``%DYNAMIC_METADATA(namespace:key)%``: Extract expected value from dynamic metadata. + // * ``%FILTER_STATE(key)%``: Extract expected value from filter state. + // * ``%DOWNSTREAM_REMOTE_ADDRESS%``: Use downstream connection IP address. + // * Plain strings: Use a static expected value. + // + // If empty, node ID validation is skipped. + // + // Example using dynamic metadata allowlist: + // + // .. code-block:: yaml + // + // node_id_format: "%DYNAMIC_METADATA(envoy.reverse_tunnel.allowlist:expected_node_id)%" + // + string node_id_format = 1 [(validate.rules).string = {max_len: 1024}]; + + // Format string to extract the expected cluster identifier for validation. + // The formatted value is compared against the ``x-envoy-reverse-tunnel-cluster-id`` header + // from the incoming handshake request. If they do not match, the connection is rejected + // with HTTP ``403 Forbidden``. + // + // Supports the same :ref:`command operators ` as + // ``node_id_format``. + // + // If empty, cluster ID validation is skipped. + // + // Example using filter state: + // + // .. code-block:: yaml + // + // cluster_id_format: "%FILTER_STATE(expected_cluster_id)%" + // + string cluster_id_format = 2 [(validate.rules).string = {max_len: 1024}]; + + // Whether to emit validation results as dynamic metadata. + // When enabled, the filter emits metadata under the namespace specified by + // ``dynamic_metadata_namespace`` containing: + // + // * ``node_id``: The actual node ID from the handshake request. + // * ``cluster_id``: The actual cluster ID from the handshake request. + // * ``validation_result``: Either ``allowed`` or ``denied``. + // + // This metadata can be used by subsequent filters or for access logging. + // Defaults to ``false``. + bool emit_dynamic_metadata = 3; + + // Namespace for emitted dynamic metadata when ``emit_dynamic_metadata`` is ``true``. + // If not specified, defaults to ``envoy.filters.network.reverse_tunnel``. + string dynamic_metadata_namespace = 4 [(validate.rules).string = {max_len: 255}]; +} + // Configuration for the reverse tunnel network filter. // This filter handles reverse tunnel connection acceptance and rejection by processing // HTTP requests where required identification values are provided via HTTP headers. +// [#next-free-field: 6] message ReverseTunnel { // Ping interval for health checks on established reverse tunnel connections. - // If not specified, defaults to 2 seconds. + // If not specified, defaults to ``2 seconds``. google.protobuf.Duration ping_interval = 1 [(validate.rules).duration = { lte {seconds: 300} gte {nanos: 1000000} }]; // Whether to automatically close connections after processing reverse tunnel requests. - // When set to true, connections are closed after acceptance or rejection. - // When set to false, connections remain open for potential reuse. Defaults to false. + // + // * When set to ``true``, connections are closed after acceptance or rejection. + // * When set to ``false``, connections remain open for potential reuse. + // + // Defaults to ``false``. bool auto_close_connections = 2; // HTTP path to match for reverse tunnel requests. - // If not specified, defaults to "/reverse_connections/request". + // If not specified, defaults to ``/reverse_connections/request``. string request_path = 3 [(validate.rules).string = {min_len: 1 max_len: 255 ignore_empty: true}]; // HTTP method to match for reverse tunnel requests. // If not specified (``METHOD_UNSPECIFIED``), this defaults to ``GET``. config.core.v3.RequestMethod request_method = 4 [(validate.rules).enum = {defined_only: true}]; + + // Optional validation configuration for node and cluster identifiers. + // If specified, the filter validates the ``x-envoy-reverse-tunnel-node-id`` and + // ``x-envoy-reverse-tunnel-cluster-id`` headers against expected values extracted + // using format strings. Requests that fail validation are rejected with HTTP ``403 Forbidden``. + Validation validation = 5; } diff --git a/source/extensions/filters/network/reverse_tunnel/BUILD b/source/extensions/filters/network/reverse_tunnel/BUILD index 56466ccedf0df..b764142ecbd4f 100644 --- a/source/extensions/filters/network/reverse_tunnel/BUILD +++ b/source/extensions/filters/network/reverse_tunnel/BUILD @@ -27,6 +27,7 @@ envoy_cc_library( hdrs = ["reverse_tunnel_filter.h"], deps = [ "//envoy/buffer:buffer_interface", + "//envoy/formatter:substitution_formatter_interface", "//envoy/http:codec_interface", "//envoy/network:connection_interface", "//envoy/network:filter_interface", @@ -34,6 +35,8 @@ envoy_cc_library( "//envoy/thread_local:thread_local_interface", "//source/common/buffer:buffer_lib", "//source/common/common:logger_lib", + "//source/common/formatter:substitution_format_string_lib", + "//source/common/formatter:substitution_formatter_lib", "//source/common/http:codes_lib", "//source/common/http:header_map_lib", "//source/common/http:headers_lib", @@ -51,7 +54,9 @@ envoy_cc_library( "//source/extensions/bootstrap/reverse_tunnel/upstream_socket_interface:reverse_tunnel_acceptor_includes", "//source/extensions/bootstrap/reverse_tunnel/upstream_socket_interface:reverse_tunnel_acceptor_lib", "//source/extensions/bootstrap/reverse_tunnel/upstream_socket_interface:upstream_socket_manager_lib", + "//source/server:generic_factory_context_lib", "//source/server:null_overload_manager_lib", + "@envoy_api//envoy/config/core/v3:pkg_cc_proto", "@envoy_api//envoy/extensions/filters/network/reverse_tunnel/v3:pkg_cc_proto", ], ) diff --git a/source/extensions/filters/network/reverse_tunnel/config.cc b/source/extensions/filters/network/reverse_tunnel/config.cc index 31aca98c60520..429a3d168d133 100644 --- a/source/extensions/filters/network/reverse_tunnel/config.cc +++ b/source/extensions/filters/network/reverse_tunnel/config.cc @@ -7,10 +7,16 @@ namespace Extensions { namespace NetworkFilters { namespace ReverseTunnel { -Network::FilterFactoryCb ReverseTunnelFilterConfigFactory::createFilterFactoryFromProtoTyped( +absl::StatusOr +ReverseTunnelFilterConfigFactory::createFilterFactoryFromProtoTyped( const envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel& proto_config, Server::Configuration::FactoryContext& context) { - auto config = std::make_shared(proto_config, context); + auto config_or_error = ReverseTunnelFilterConfig::create(proto_config, context); + if (!config_or_error.ok()) { + return config_or_error.status(); + } + auto config = config_or_error.value(); + // Capture scope and overload manager pointers to avoid dangling references. Stats::Scope* scope = &context.scope(); Server::OverloadManager* overload_manager = &context.serverFactoryContext().overloadManager(); diff --git a/source/extensions/filters/network/reverse_tunnel/config.h b/source/extensions/filters/network/reverse_tunnel/config.h index fd1e24ccf77ef..7cb447364682a 100644 --- a/source/extensions/filters/network/reverse_tunnel/config.h +++ b/source/extensions/filters/network/reverse_tunnel/config.h @@ -15,15 +15,16 @@ namespace ReverseTunnel { * Config registration for the reverse tunnel network filter. */ class ReverseTunnelFilterConfigFactory - : public Common::FactoryBase< + : public Common::ExceptionFreeFactoryBase< envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel> { public: // Always mark the reverse tunnel filter as terminal filter. ReverseTunnelFilterConfigFactory() - : FactoryBase(NetworkFilterNames::get().ReverseTunnel, true /* isTerminalFilter */) {} + : ExceptionFreeFactoryBase(NetworkFilterNames::get().ReverseTunnel, + true /* isTerminalFilter */) {} private: - Network::FilterFactoryCb createFilterFactoryFromProtoTyped( + absl::StatusOr createFilterFactoryFromProtoTyped( const envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel& proto_config, Server::Configuration::FactoryContext& context) override; }; diff --git a/source/extensions/filters/network/reverse_tunnel/reverse_tunnel_filter.cc b/source/extensions/filters/network/reverse_tunnel/reverse_tunnel_filter.cc index 92fa88664a386..105123277a04f 100644 --- a/source/extensions/filters/network/reverse_tunnel/reverse_tunnel_filter.cc +++ b/source/extensions/filters/network/reverse_tunnel/reverse_tunnel_filter.cc @@ -1,10 +1,14 @@ #include "source/extensions/filters/network/reverse_tunnel/reverse_tunnel_filter.h" #include "envoy/buffer/buffer.h" +#include "envoy/config/core/v3/substitution_format_string.pb.h" #include "envoy/network/connection.h" #include "envoy/server/overload/overload_manager.h" #include "source/common/buffer/buffer_impl.h" +#include "source/common/config/datasource.h" +#include "source/common/formatter/substitution_format_string.h" +#include "source/common/formatter/substitution_formatter.h" #include "source/common/http/codes.h" #include "source/common/http/header_map_impl.h" #include "source/common/http/headers.h" @@ -15,6 +19,7 @@ #include "source/extensions/bootstrap/reverse_tunnel/upstream_socket_interface/reverse_tunnel_acceptor.h" #include "source/extensions/bootstrap/reverse_tunnel/upstream_socket_interface/reverse_tunnel_acceptor_extension.h" #include "source/extensions/bootstrap/reverse_tunnel/upstream_socket_interface/upstream_socket_manager.h" +#include "source/server/generic_factory_context.h" namespace Envoy { namespace Extensions { @@ -29,9 +34,59 @@ ReverseTunnelFilter::ReverseTunnelStats::generateStats(const std::string& prefix } // ReverseTunnelFilterConfig implementation. +absl::StatusOr> ReverseTunnelFilterConfig::create( + const envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel& proto_config, + Server::Configuration::FactoryContext& context) { + + Formatter::FormatterConstSharedPtr node_id_formatter; + Formatter::FormatterConstSharedPtr cluster_id_formatter; + + // Create formatters for validation if configured. + if (proto_config.has_validation()) { + Server::GenericFactoryContextImpl generic_context(context.serverFactoryContext(), + context.messageValidationVisitor()); + + const auto& validation = proto_config.validation(); + + // Create node_id formatter if configured. + if (!validation.node_id_format().empty()) { + envoy::config::core::v3::SubstitutionFormatString node_id_format_config; + node_id_format_config.mutable_text_format_source()->set_inline_string( + validation.node_id_format()); + + auto formatter_or_error = Formatter::SubstitutionFormatStringUtils::fromProtoConfig( + node_id_format_config, generic_context); + if (!formatter_or_error.ok()) { + return absl::InvalidArgumentError(fmt::format("Failed to parse node_id_format: {}", + formatter_or_error.status().message())); + } + node_id_formatter = std::move(formatter_or_error.value()); + } + + // Create cluster_id formatter if configured. + if (!validation.cluster_id_format().empty()) { + envoy::config::core::v3::SubstitutionFormatString cluster_id_format_config; + cluster_id_format_config.mutable_text_format_source()->set_inline_string( + validation.cluster_id_format()); + + auto formatter_or_error = Formatter::SubstitutionFormatStringUtils::fromProtoConfig( + cluster_id_format_config, generic_context); + if (!formatter_or_error.ok()) { + return absl::InvalidArgumentError(fmt::format("Failed to parse cluster_id_format: {}", + formatter_or_error.status().message())); + } + cluster_id_formatter = std::move(formatter_or_error.value()); + } + } + + return std::shared_ptr(new ReverseTunnelFilterConfig( + proto_config, std::move(node_id_formatter), std::move(cluster_id_formatter))); +} + ReverseTunnelFilterConfig::ReverseTunnelFilterConfig( const envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel& proto_config, - Server::Configuration::FactoryContext&) + Formatter::FormatterConstSharedPtr node_id_formatter, + Formatter::FormatterConstSharedPtr cluster_id_formatter) : ping_interval_(proto_config.has_ping_interval() ? std::chrono::milliseconds( DurationUtil::durationToMilliseconds(proto_config.ping_interval())) @@ -46,7 +101,77 @@ ReverseTunnelFilterConfig::ReverseTunnelFilterConfig( method = envoy::config::core::v3::GET; } return envoy::config::core::v3::RequestMethod_Name(method); - }()) {} + }()), + node_id_formatter_(std::move(node_id_formatter)), + cluster_id_formatter_(std::move(cluster_id_formatter)), + emit_dynamic_metadata_(proto_config.has_validation() && + proto_config.validation().emit_dynamic_metadata()), + dynamic_metadata_namespace_( + proto_config.has_validation() && + !proto_config.validation().dynamic_metadata_namespace().empty() + ? proto_config.validation().dynamic_metadata_namespace() + : "envoy.filters.network.reverse_tunnel") {} + +bool ReverseTunnelFilterConfig::validateIdentifiers( + absl::string_view node_id, absl::string_view cluster_id, + const StreamInfo::StreamInfo& stream_info) const { + + // If no validation configured, pass validation. + if (!node_id_formatter_ && !cluster_id_formatter_) { + return true; + } + + // Validate node_id if formatter is configured. + if (node_id_formatter_) { + const std::string expected_node_id = node_id_formatter_->formatWithContext({}, stream_info); + if (!expected_node_id.empty() && expected_node_id != node_id) { + ENVOY_LOG(debug, "reverse_tunnel: node_id validation failed. Expected: '{}', Actual: '{}'", + expected_node_id, node_id); + return false; + } + } + + // Validate cluster_id if formatter is configured. + if (cluster_id_formatter_) { + const std::string expected_cluster_id = + cluster_id_formatter_->formatWithContext({}, stream_info); + if (!expected_cluster_id.empty() && expected_cluster_id != cluster_id) { + ENVOY_LOG(debug, "reverse_tunnel: cluster_id validation failed. Expected: '{}', Actual: '{}'", + expected_cluster_id, cluster_id); + return false; + } + } + + return true; +} + +void ReverseTunnelFilterConfig::emitValidationMetadata(absl::string_view node_id, + absl::string_view cluster_id, + bool validation_passed, + StreamInfo::StreamInfo& stream_info) const { + if (!emit_dynamic_metadata_) { + return; + } + + Protobuf::Struct metadata; + auto& fields = *metadata.mutable_fields(); + + // Emit actual identifiers. + fields["node_id"].set_string_value(std::string(node_id)); + fields["cluster_id"].set_string_value(std::string(cluster_id)); + + // Emit validation result. + fields["validation_result"].set_string_value(validation_passed ? "allowed" : "denied"); + + // Set dynamic metadata on the stream info. + stream_info.setDynamicMetadata(dynamic_metadata_namespace_, metadata); + + ENVOY_LOG(trace, + "reverse_tunnel: emitted dynamic metadata to namespace '{}': node_id={}, " + "cluster_id={}, validation_result={}", + dynamic_metadata_namespace_, node_id, cluster_id, + validation_passed ? "allowed" : "denied"); +} // ReverseTunnelFilter implementation. ReverseTunnelFilter::ReverseTunnelFilter(ReverseTunnelFilterConfigSharedPtr config, @@ -191,6 +316,25 @@ void ReverseTunnelFilter::RequestDecoderImpl::processIfComplete(bool end_stream) const absl::string_view cluster_id = cluster_vals[0]->value().getStringView(); const absl::string_view tenant_id = tenant_vals[0]->value().getStringView(); + // Validate node_id and cluster_id if validation is configured. + auto& connection = parent_.read_callbacks_->connection(); + const bool validation_passed = + parent_.config_->validateIdentifiers(node_id, cluster_id, connection.streamInfo()); + + // Emit validation metadata if configured. + parent_.config_->emitValidationMetadata(node_id, cluster_id, validation_passed, + connection.streamInfo()); + + if (!validation_passed) { + parent_.stats_.validation_failed_.inc(); + ENVOY_CONN_LOG(debug, "reverse_tunnel: validation failed for node '{}', cluster '{}'", + parent_.read_callbacks_->connection(), node_id, cluster_id); + sendLocalReply(Http::Code::Forbidden, "Validation failed", nullptr, absl::nullopt, + "reverse_tunnel_validation_failed"); + parent_.read_callbacks_->connection().close(Network::ConnectionCloseType::FlushWrite); + return; + } + // Respond with 200 OK. auto resp_headers = Http::ResponseHeaderMapImpl::create(); resp_headers->setStatus(200); diff --git a/source/extensions/filters/network/reverse_tunnel/reverse_tunnel_filter.h b/source/extensions/filters/network/reverse_tunnel/reverse_tunnel_filter.h index 15558283b399f..180658950c6f2 100644 --- a/source/extensions/filters/network/reverse_tunnel/reverse_tunnel_filter.h +++ b/source/extensions/filters/network/reverse_tunnel/reverse_tunnel_filter.h @@ -1,6 +1,7 @@ #pragma once #include "envoy/extensions/filters/network/reverse_tunnel/v3/reverse_tunnel.pb.h" +#include "envoy/formatter/substitution_formatter.h" #include "envoy/http/codec.h" #include "envoy/network/filter.h" #include "envoy/server/factory_context.h" @@ -25,22 +26,47 @@ namespace ReverseTunnel { /** * Configuration for the reverse tunnel network filter. */ -class ReverseTunnelFilterConfig { +class ReverseTunnelFilterConfig : public Logger::Loggable { public: - ReverseTunnelFilterConfig( - const envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel& proto_config, - Server::Configuration::FactoryContext& context); + static absl::StatusOr> + create(const envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel& proto_config, + Server::Configuration::FactoryContext& context); std::chrono::milliseconds pingInterval() const { return ping_interval_; } bool autoCloseConnections() const { return auto_close_connections_; } const std::string& requestPath() const { return request_path_; } const std::string& requestMethod() const { return request_method_string_; } + // Returns true if validation is configured. + bool hasValidation() const { + return node_id_formatter_ != nullptr || cluster_id_formatter_ != nullptr; + } + + // Validates the extracted node_id and cluster_id against expected values. + // Returns true if validation passes or no validation is configured. + bool validateIdentifiers(absl::string_view node_id, absl::string_view cluster_id, + const StreamInfo::StreamInfo& stream_info) const; + + // Emits validation results as dynamic metadata if configured. + void emitValidationMetadata(absl::string_view node_id, absl::string_view cluster_id, + bool validation_passed, StreamInfo::StreamInfo& stream_info) const; + private: + ReverseTunnelFilterConfig( + const envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel& proto_config, + Formatter::FormatterConstSharedPtr node_id_formatter, + Formatter::FormatterConstSharedPtr cluster_id_formatter); + const std::chrono::milliseconds ping_interval_; const bool auto_close_connections_; const std::string request_path_; const std::string request_method_string_; + + // Validation configuration. + Formatter::FormatterConstSharedPtr node_id_formatter_; + Formatter::FormatterConstSharedPtr cluster_id_formatter_; + const bool emit_dynamic_metadata_{false}; + const std::string dynamic_metadata_namespace_; }; using ReverseTunnelFilterConfigSharedPtr = std::shared_ptr; @@ -75,7 +101,8 @@ class ReverseTunnelFilter : public Network::ReadFilter, #define ALL_REVERSE_TUNNEL_HANDSHAKE_STATS(COUNTER) \ COUNTER(parse_error) \ COUNTER(accepted) \ - COUNTER(rejected) + COUNTER(rejected) \ + COUNTER(validation_failed) struct ReverseTunnelStats { ALL_REVERSE_TUNNEL_HANDSHAKE_STATS(GENERATE_COUNTER_STRUCT) diff --git a/test/extensions/filters/network/reverse_tunnel/config_test.cc b/test/extensions/filters/network/reverse_tunnel/config_test.cc index 0e048c762d15e..fa7dd58e32bd5 100644 --- a/test/extensions/filters/network/reverse_tunnel/config_test.cc +++ b/test/extensions/filters/network/reverse_tunnel/config_test.cc @@ -155,6 +155,162 @@ request_method: PUT cb(filter_manager); } +TEST(ReverseTunnelFilterConfigFactoryTest, ConfigurationWithValidation) { + ReverseTunnelFilterConfigFactory factory; + + const std::string yaml_string = R"EOF( +ping_interval: + seconds: 5 +auto_close_connections: false +request_path: "/reverse_connections/request" +request_method: GET +validation: + node_id_format: "expected-node-id" + cluster_id_format: "expected-cluster-id" + emit_dynamic_metadata: true + dynamic_metadata_namespace: "envoy.filters.network.reverse_tunnel" +)EOF"; + + envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel proto_config; + TestUtility::loadFromYaml(yaml_string, proto_config); + + NiceMock context; + auto result = factory.createFilterFactoryFromProto(proto_config, context); + ASSERT_TRUE(result.ok()); + Network::FilterFactoryCb cb = result.value(); + + EXPECT_TRUE(cb != nullptr); + + Network::MockFilterManager filter_manager; + EXPECT_CALL(filter_manager, addReadFilter(_)); + cb(filter_manager); +} + +TEST(ReverseTunnelFilterConfigFactoryTest, ConfigurationWithStaticValidation) { + ReverseTunnelFilterConfigFactory factory; + + const std::string yaml_string = R"EOF( +request_path: "/reverse_connections/request" +request_method: GET +validation: + node_id_format: "expected-static-node" + cluster_id_format: "expected-static-cluster" +)EOF"; + + envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel proto_config; + TestUtility::loadFromYaml(yaml_string, proto_config); + + NiceMock context; + auto result = factory.createFilterFactoryFromProto(proto_config, context); + ASSERT_TRUE(result.ok()); + Network::FilterFactoryCb cb = result.value(); + + EXPECT_TRUE(cb != nullptr); + + Network::MockFilterManager filter_manager; + EXPECT_CALL(filter_manager, addReadFilter(_)); + cb(filter_manager); +} + +TEST(ReverseTunnelFilterConfigFactoryTest, ConfigurationWithMetadataEmission) { + ReverseTunnelFilterConfigFactory factory; + + const std::string yaml_string = R"EOF( +request_path: "/reverse_connections/request" +request_method: GET +validation: + node_id_format: "test-node" + cluster_id_format: "test-cluster" + emit_dynamic_metadata: true + dynamic_metadata_namespace: "custom.namespace" +)EOF"; + + envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel proto_config; + TestUtility::loadFromYaml(yaml_string, proto_config); + + NiceMock context; + auto result = factory.createFilterFactoryFromProto(proto_config, context); + ASSERT_TRUE(result.ok()); + Network::FilterFactoryCb cb = result.value(); + + EXPECT_TRUE(cb != nullptr); + + Network::MockFilterManager filter_manager; + EXPECT_CALL(filter_manager, addReadFilter(_)); + cb(filter_manager); +} + +TEST(ReverseTunnelFilterConfigFactoryTest, ConfigurationWithInvalidFormatter) { + ReverseTunnelFilterConfigFactory factory; + + const std::string yaml_string = R"EOF( +request_path: "/reverse_connections/request" +request_method: GET +validation: + node_id_format: "%INVALID_FORMATTER_COMMAND()%" + cluster_id_format: "valid-cluster" +)EOF"; + + envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel proto_config; + TestUtility::loadFromYaml(yaml_string, proto_config); + + NiceMock context; + + auto result = factory.createFilterFactoryFromProto(proto_config, context); + ASSERT_FALSE(result.ok()); + EXPECT_THAT(result.status().message(), testing::HasSubstr("Failed to parse node_id_format")); +} + +TEST(ReverseTunnelFilterConfigFactoryTest, ConfigurationWithOnlyNodeIdValidation) { + ReverseTunnelFilterConfigFactory factory; + + const std::string yaml_string = R"EOF( +request_path: "/reverse_connections/request" +request_method: GET +validation: + node_id_format: "expected-node" +)EOF"; + + envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel proto_config; + TestUtility::loadFromYaml(yaml_string, proto_config); + + NiceMock context; + auto result = factory.createFilterFactoryFromProto(proto_config, context); + ASSERT_TRUE(result.ok()); + Network::FilterFactoryCb cb = result.value(); + + EXPECT_TRUE(cb != nullptr); + + Network::MockFilterManager filter_manager; + EXPECT_CALL(filter_manager, addReadFilter(_)); + cb(filter_manager); +} + +TEST(ReverseTunnelFilterConfigFactoryTest, ConfigurationWithOnlyClusterIdValidation) { + ReverseTunnelFilterConfigFactory factory; + + const std::string yaml_string = R"EOF( +request_path: "/reverse_connections/request" +request_method: GET +validation: + cluster_id_format: "expected-cluster" +)EOF"; + + envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel proto_config; + TestUtility::loadFromYaml(yaml_string, proto_config); + + NiceMock context; + auto result = factory.createFilterFactoryFromProto(proto_config, context); + ASSERT_TRUE(result.ok()); + Network::FilterFactoryCb cb = result.value(); + + EXPECT_TRUE(cb != nullptr); + + Network::MockFilterManager filter_manager; + EXPECT_CALL(filter_manager, addReadFilter(_)); + cb(filter_manager); +} + } // namespace } // namespace ReverseTunnel } // namespace NetworkFilters diff --git a/test/extensions/filters/network/reverse_tunnel/filter_unit_test.cc b/test/extensions/filters/network/reverse_tunnel/filter_unit_test.cc index 875b351b1df29..16cec37eaf43d 100644 --- a/test/extensions/filters/network/reverse_tunnel/filter_unit_test.cc +++ b/test/extensions/filters/network/reverse_tunnel/filter_unit_test.cc @@ -64,7 +64,11 @@ class ReverseTunnelFilterUnitTest : public testing::Test { // Prepare proto config with defaults. proto_config_.set_request_path("/reverse_connections/request"); proto_config_.set_request_method(envoy::config::core::v3::GET); - config_ = std::make_shared(proto_config_, factory_context_); + auto config_or_error = ReverseTunnelFilterConfig::create(proto_config_, factory_context_); + if (!config_or_error.ok()) { + throw EnvoyException(std::string(config_or_error.status().message())); + } + config_ = config_or_error.value(); filter_ = std::make_unique(config_, *stats_store_.rootScope(), overload_manager_); @@ -212,7 +216,9 @@ TEST_F(ReverseTunnelFilterUnitTest, FullFlowAccepts) { // Configure reverse tunnel filter. envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel cfg; - auto local_config = std::make_shared(cfg, factory_context_); + auto config_or_error = ReverseTunnelFilterConfig::create(cfg, factory_context_); + ASSERT_TRUE(config_or_error.ok()); + auto local_config = config_or_error.value(); ReverseTunnelFilter filter(local_config, *stats_store_.rootScope(), overload_manager_); EXPECT_CALL(callbacks_, connection()).WillRepeatedly(ReturnRef(callbacks_.connection_)); filter.initializeReadFilterCallbacks(callbacks_); @@ -241,7 +247,9 @@ TEST_F(ReverseTunnelFilterUnitTest, FullFlowAccepts) { TEST_F(ReverseTunnelFilterUnitTest, FullFlowMissingHeadersIsBadRequest) { envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel cfg; - auto local_config = std::make_shared(cfg, factory_context_); + auto config_or_error = ReverseTunnelFilterConfig::create(cfg, factory_context_); + ASSERT_TRUE(config_or_error.ok()); + auto local_config = config_or_error.value(); ReverseTunnelFilter filter(local_config, *stats_store_.rootScope(), overload_manager_); EXPECT_CALL(callbacks_, connection()).WillRepeatedly(ReturnRef(callbacks_.connection_)); filter.initializeReadFilterCallbacks(callbacks_); @@ -292,7 +300,9 @@ TEST_F(ReverseTunnelFilterUnitTest, NotFoundForNonReverseTunnelPath) { TEST_F(ReverseTunnelFilterUnitTest, AutoCloseConnectionsClosesAfterAccept) { envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel cfg; cfg.set_auto_close_connections(true); - auto local_config = std::make_shared(cfg, factory_context_); + auto config_or_error = ReverseTunnelFilterConfig::create(cfg, factory_context_); + ASSERT_TRUE(config_or_error.ok()); + auto local_config = config_or_error.value(); ReverseTunnelFilter filter(local_config, *stats_store_.rootScope(), overload_manager_); EXPECT_CALL(callbacks_, connection()).WillRepeatedly(ReturnRef(callbacks_.connection_)); filter.initializeReadFilterCallbacks(callbacks_); @@ -347,18 +357,22 @@ TEST_F(ReverseTunnelFilterUnitTest, ConfigurationCustomPingInterval) { proto_config.set_request_path("/custom/path"); proto_config.set_request_method(envoy::config::core::v3::PUT); - ReverseTunnelFilterConfig config(proto_config, factory_context_); - EXPECT_EQ(std::chrono::milliseconds(10000), config.pingInterval()); - EXPECT_TRUE(config.autoCloseConnections()); - EXPECT_EQ("/custom/path", config.requestPath()); - EXPECT_EQ("PUT", config.requestMethod()); + auto config_or_error = ReverseTunnelFilterConfig::create(proto_config, factory_context_); + ASSERT_TRUE(config_or_error.ok()); + auto config = config_or_error.value(); + EXPECT_EQ(std::chrono::milliseconds(10000), config->pingInterval()); + EXPECT_TRUE(config->autoCloseConnections()); + EXPECT_EQ("/custom/path", config->requestPath()); + EXPECT_EQ("PUT", config->requestMethod()); } // Ensure defaults remain stable. TEST_F(ReverseTunnelFilterUnitTest, ConfigurationDefaultsRemainStable) { envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel proto_config; - ReverseTunnelFilterConfig config(proto_config, factory_context_); - EXPECT_EQ("/reverse_connections/request", config.requestPath()); + auto config_or_error = ReverseTunnelFilterConfig::create(proto_config, factory_context_); + ASSERT_TRUE(config_or_error.ok()); + auto config = config_or_error.value(); + EXPECT_EQ("/reverse_connections/request", config->requestPath()); } // Test configuration with default values. @@ -366,11 +380,13 @@ TEST_F(ReverseTunnelFilterUnitTest, ConfigurationDefaults) { envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel proto_config; // Leave everything empty to test defaults. - ReverseTunnelFilterConfig config(proto_config, factory_context_); - EXPECT_EQ(std::chrono::milliseconds(2000), config.pingInterval()); - EXPECT_FALSE(config.autoCloseConnections()); - EXPECT_EQ("/reverse_connections/request", config.requestPath()); - EXPECT_EQ("GET", config.requestMethod()); + auto config_or_error = ReverseTunnelFilterConfig::create(proto_config, factory_context_); + ASSERT_TRUE(config_or_error.ok()); + auto config = config_or_error.value(); + EXPECT_EQ(std::chrono::milliseconds(2000), config->pingInterval()); + EXPECT_FALSE(config->autoCloseConnections()); + EXPECT_EQ("/reverse_connections/request", config->requestPath()); + EXPECT_EQ("GET", config->requestMethod()); } // Test RequestDecoder methods not fully covered. @@ -475,7 +491,9 @@ TEST_F(ReverseTunnelFilterUnitTest, ParseEmptyPayload) { TEST_F(ReverseTunnelFilterUnitTest, NonStringFilterStateIgnored) { envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel cfg; - auto local_config = std::make_shared(cfg, factory_context_); + auto config_or_error = ReverseTunnelFilterConfig::create(cfg, factory_context_); + ASSERT_TRUE(config_or_error.ok()); + auto local_config = config_or_error.value(); ReverseTunnelFilter filter(local_config, *stats_store_.rootScope(), overload_manager_); EXPECT_CALL(callbacks_, connection()).WillRepeatedly(ReturnRef(callbacks_.connection_)); filter.initializeReadFilterCallbacks(callbacks_); @@ -495,7 +513,9 @@ TEST_F(ReverseTunnelFilterUnitTest, NonStringFilterStateIgnored) { TEST_F(ReverseTunnelFilterUnitTest, ClusterIdMismatchIgnored) { envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel cfg; - auto local_config = std::make_shared(cfg, factory_context_); + auto config_or_error = ReverseTunnelFilterConfig::create(cfg, factory_context_); + ASSERT_TRUE(config_or_error.ok()); + auto local_config = config_or_error.value(); ReverseTunnelFilter filter(local_config, *stats_store_.rootScope(), overload_manager_); EXPECT_CALL(callbacks_, connection()).WillRepeatedly(ReturnRef(callbacks_.connection_)); filter.initializeReadFilterCallbacks(callbacks_); @@ -515,7 +535,9 @@ TEST_F(ReverseTunnelFilterUnitTest, ClusterIdMismatchIgnored) { TEST_F(ReverseTunnelFilterUnitTest, TenantIdMissingIgnored) { envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel cfg; - auto local_config = std::make_shared(cfg, factory_context_); + auto config_or_error = ReverseTunnelFilterConfig::create(cfg, factory_context_); + ASSERT_TRUE(config_or_error.ok()); + auto local_config = config_or_error.value(); ReverseTunnelFilter filter(local_config, *stats_store_.rootScope(), overload_manager_); EXPECT_CALL(callbacks_, connection()).WillRepeatedly(ReturnRef(callbacks_.connection_)); filter.initializeReadFilterCallbacks(callbacks_); @@ -796,7 +818,9 @@ TEST_F(ReverseTunnelFilterUnitTest, CompleteRequestSingleCall) { TEST_F(ReverseTunnelFilterUnitTest, PartialStateIgnored) { envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel cfg; - auto local_config = std::make_shared(cfg, factory_context_); + auto config_or_error = ReverseTunnelFilterConfig::create(cfg, factory_context_); + ASSERT_TRUE(config_or_error.ok()); + auto local_config = config_or_error.value(); ReverseTunnelFilter filter(local_config, *stats_store_.rootScope(), overload_manager_); EXPECT_CALL(callbacks_, connection()).WillRepeatedly(ReturnRef(callbacks_.connection_)); filter.initializeReadFilterCallbacks(callbacks_); @@ -886,15 +910,19 @@ TEST_F(ReverseTunnelFilterUnitTest, ConfigurationAllBranches) { envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel cfg; cfg.mutable_ping_interval()->set_seconds(5); cfg.mutable_ping_interval()->set_nanos(500000000); - ReverseTunnelFilterConfig config(cfg, factory_context_); - EXPECT_EQ(std::chrono::milliseconds(5500), config.pingInterval()); + auto config_or_error = ReverseTunnelFilterConfig::create(cfg, factory_context_); + ASSERT_TRUE(config_or_error.ok()); + auto config = config_or_error.value(); + EXPECT_EQ(std::chrono::milliseconds(5500), config->pingInterval()); } // Test config without ping_interval (default). { envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel cfg; - ReverseTunnelFilterConfig config(cfg, factory_context_); - EXPECT_EQ(std::chrono::milliseconds(2000), config.pingInterval()); + auto config_or_error = ReverseTunnelFilterConfig::create(cfg, factory_context_); + ASSERT_TRUE(config_or_error.ok()); + auto config = config_or_error.value(); + EXPECT_EQ(std::chrono::milliseconds(2000), config->pingInterval()); } // Test config with empty strings (should use defaults). @@ -902,9 +930,11 @@ TEST_F(ReverseTunnelFilterUnitTest, ConfigurationAllBranches) { envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel cfg; cfg.set_request_path(""); cfg.set_request_method(envoy::config::core::v3::METHOD_UNSPECIFIED); - ReverseTunnelFilterConfig config(cfg, factory_context_); - EXPECT_EQ("/reverse_connections/request", config.requestPath()); - EXPECT_EQ("GET", config.requestMethod()); + auto config_or_error = ReverseTunnelFilterConfig::create(cfg, factory_context_); + ASSERT_TRUE(config_or_error.ok()); + auto config = config_or_error.value(); + EXPECT_EQ("/reverse_connections/request", config->requestPath()); + EXPECT_EQ("GET", config->requestMethod()); } } @@ -987,7 +1017,9 @@ TEST_F(ReverseTunnelFilterUnitTest, CodecDispatchError) { TEST_F(ReverseTunnelFilterUnitTest, TenantIdMismatchIgnored2) { envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel cfg; - auto local_config = std::make_shared(cfg, factory_context_); + auto config_or_error = ReverseTunnelFilterConfig::create(cfg, factory_context_); + ASSERT_TRUE(config_or_error.ok()); + auto local_config = config_or_error.value(); ReverseTunnelFilter filter(local_config, *stats_store_.rootScope(), overload_manager_); EXPECT_CALL(callbacks_, connection()).WillRepeatedly(ReturnRef(callbacks_.connection_)); filter.initializeReadFilterCallbacks(callbacks_); @@ -1050,10 +1082,12 @@ TEST_F(ReverseTunnelFilterUnitTest, ConfigurationDeprecatedField) { cfg.set_request_method(envoy::config::core::v3::PUT); // No extra options set to test defaults. - ReverseTunnelFilterConfig config(cfg, factory_context_); - EXPECT_FALSE(config.autoCloseConnections()); - EXPECT_EQ("/test", config.requestPath()); - EXPECT_EQ("PUT", config.requestMethod()); + auto config_or_error = ReverseTunnelFilterConfig::create(cfg, factory_context_); + ASSERT_TRUE(config_or_error.ok()); + auto config = config_or_error.value(); + EXPECT_FALSE(config->autoCloseConnections()); + EXPECT_EQ("/test", config->requestPath()); + EXPECT_EQ("PUT", config->requestMethod()); } // Test decodeData with multiple chunks. diff --git a/test/extensions/filters/network/reverse_tunnel/integration_test.cc b/test/extensions/filters/network/reverse_tunnel/integration_test.cc index d9b6af8f4082d..891fed6079965 100644 --- a/test/extensions/filters/network/reverse_tunnel/integration_test.cc +++ b/test/extensions/filters/network/reverse_tunnel/integration_test.cc @@ -106,7 +106,8 @@ name: envoy.filters.network.set_filter_state void addReverseTunnelFilter(bool auto_close_connections = false, const std::string& request_path = "/reverse_connections/request", - const std::string& request_method = "GET") { + const std::string& request_method = "GET", + const std::string& validation_config = "") { const std::string filter_config = fmt::format(R"EOF( name: envoy.filters.network.reverse_tunnel @@ -116,9 +117,10 @@ name: envoy.filters.network.set_filter_state seconds: 300 auto_close_connections: {} request_path: "{}" - request_method: {} + request_method: {}{} )EOF", - auto_close_connections ? "true" : "false", request_path, request_method); + auto_close_connections ? "true" : "false", request_path, request_method, + validation_config.empty() ? "" : "\n" + validation_config); config_helper_.addConfigModifier( [filter_config](envoy::config::bootstrap::v3::Bootstrap& bootstrap) { @@ -480,6 +482,715 @@ TEST_P(ReverseTunnelFilterIntegrationTest, EndToEndReverseConnectionHandshake) { 2); // 2 listeners in this test } +// Test validation with static expected values. +TEST_P(ReverseTunnelFilterIntegrationTest, ValidationWithStaticValuesSuccess) { + const std::string validation_config = R"( + validation: + node_id_format: "test-node" + cluster_id_format: "test-cluster" + emit_dynamic_metadata: true)"; + + addReverseTunnelFilter(false, "/reverse_connections/request", "GET", validation_config); + initialize(); + + std::string http_request = createHttpRequestWithRtHeaders( + "GET", "/reverse_connections/request", "test-node", "test-cluster", "test-tenant"); + + IntegrationTcpClientPtr tcp_client = makeTcpConnection(lookupPort("listener_0")); + ASSERT_TRUE(tcp_client->write(http_request)); + tcp_client->waitForData("HTTP/1.1 200 OK"); + tcp_client->close(); + + test_server_->waitForCounterGe("reverse_tunnel.handshake.accepted", 1); +} + +// Test validation with static expected values. +TEST_P(ReverseTunnelFilterIntegrationTest, ValidationWithStaticValuesFailure) { + const std::string validation_config = R"( + validation: + node_id_format: "expected-node" + cluster_id_format: "expected-cluster")"; + + addReverseTunnelFilter(false, "/reverse_connections/request", "GET", validation_config); + initialize(); + + std::string http_request = createHttpRequestWithRtHeaders( + "GET", "/reverse_connections/request", "wrong-node", "wrong-cluster", "test-tenant"); + + IntegrationTcpClientPtr tcp_client = makeTcpConnection(lookupPort("listener_0")); + ASSERT_TRUE(tcp_client->write(http_request)); + tcp_client->waitForData("HTTP/1.1 403 Forbidden"); + tcp_client->waitForDisconnect(); + + test_server_->waitForCounterGe("reverse_tunnel.handshake.validation_failed", 1); +} + +// Test validation with only node_id validation. +TEST_P(ReverseTunnelFilterIntegrationTest, ValidationOnlyNodeId) { + const std::string validation_config = R"( + validation: + node_id_format: "expected-node")"; + + addReverseTunnelFilter(false, "/reverse_connections/request", "GET", validation_config); + initialize(); + + // Success: node_id matches, cluster_id ignored. + std::string http_request_pass = createHttpRequestWithRtHeaders( + "GET", "/reverse_connections/request", "expected-node", "any-cluster", "test-tenant"); + + IntegrationTcpClientPtr tcp_client1 = makeTcpConnection(lookupPort("listener_0")); + ASSERT_TRUE(tcp_client1->write(http_request_pass)); + tcp_client1->waitForData("HTTP/1.1 200 OK"); + tcp_client1->close(); + + test_server_->waitForCounterGe("reverse_tunnel.handshake.accepted", 1); + + // Failure: node_id doesn't match. + std::string http_request_fail = createHttpRequestWithRtHeaders( + "GET", "/reverse_connections/request", "wrong-node", "any-cluster", "test-tenant"); + + IntegrationTcpClientPtr tcp_client2 = makeTcpConnection(lookupPort("listener_0")); + ASSERT_TRUE(tcp_client2->write(http_request_fail)); + tcp_client2->waitForData("HTTP/1.1 403 Forbidden"); + tcp_client2->waitForDisconnect(); + + test_server_->waitForCounterGe("reverse_tunnel.handshake.validation_failed", 1); +} + +// Test validation with only cluster_id validation. +TEST_P(ReverseTunnelFilterIntegrationTest, ValidationOnlyClusterId) { + const std::string validation_config = R"( + validation: + cluster_id_format: "expected-cluster")"; + + addReverseTunnelFilter(false, "/reverse_connections/request", "GET", validation_config); + initialize(); + + // Success: cluster_id matches, node_id ignored. + std::string http_request_pass = createHttpRequestWithRtHeaders( + "GET", "/reverse_connections/request", "any-node", "expected-cluster", "test-tenant"); + + IntegrationTcpClientPtr tcp_client1 = makeTcpConnection(lookupPort("listener_0")); + ASSERT_TRUE(tcp_client1->write(http_request_pass)); + tcp_client1->waitForData("HTTP/1.1 200 OK"); + tcp_client1->close(); + + test_server_->waitForCounterGe("reverse_tunnel.handshake.accepted", 1); + + // Failure: cluster_id doesn't match. + std::string http_request_fail = createHttpRequestWithRtHeaders( + "GET", "/reverse_connections/request", "any-node", "wrong-cluster", "test-tenant"); + + IntegrationTcpClientPtr tcp_client2 = makeTcpConnection(lookupPort("listener_0")); + ASSERT_TRUE(tcp_client2->write(http_request_fail)); + tcp_client2->waitForData("HTTP/1.1 403 Forbidden"); + tcp_client2->waitForDisconnect(); + + test_server_->waitForCounterGe("reverse_tunnel.handshake.validation_failed", 1); +} + +// Test validation with empty format strings. In this case validation is skipped. +TEST_P(ReverseTunnelFilterIntegrationTest, ValidationWithEmptyFormatters) { + const std::string validation_config = R"( + validation: + node_id_format: "" + cluster_id_format: "")"; + + addReverseTunnelFilter(false, "/reverse_connections/request", "GET", validation_config); + initialize(); + + // Should succeed since no validation is configured. + std::string http_request = createHttpRequestWithRtHeaders( + "GET", "/reverse_connections/request", "any-node", "any-cluster", "test-tenant"); + + IntegrationTcpClientPtr tcp_client = makeTcpConnection(lookupPort("listener_0")); + ASSERT_TRUE(tcp_client->write(http_request)); + tcp_client->waitForData("HTTP/1.1 200 OK"); + tcp_client->close(); + + test_server_->waitForCounterGe("reverse_tunnel.handshake.accepted", 1); +} + +// Test validation with dynamic metadata emission. +TEST_P(ReverseTunnelFilterIntegrationTest, ValidationWithDynamicMetadataEmission) { + const std::string validation_config = R"( + validation: + node_id_format: "test-node" + cluster_id_format: "test-cluster" + emit_dynamic_metadata: true + dynamic_metadata_namespace: "envoy.test.reverse_tunnel")"; + + addReverseTunnelFilter(false, "/reverse_connections/request", "GET", validation_config); + initialize(); + + std::string http_request = createHttpRequestWithRtHeaders( + "GET", "/reverse_connections/request", "test-node", "test-cluster", "test-tenant"); + + IntegrationTcpClientPtr tcp_client = makeTcpConnection(lookupPort("listener_0")); + ASSERT_TRUE(tcp_client->write(http_request)); + tcp_client->waitForData("HTTP/1.1 200 OK"); + tcp_client->close(); + + test_server_->waitForCounterGe("reverse_tunnel.handshake.accepted", 1); +} + +// Test validation with multiple formatters in format string. +TEST_P(ReverseTunnelFilterIntegrationTest, ValidationWithComplexFormatString) { + const std::string validation_config = R"( + validation: + node_id_format: "prefix-%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%-suffix" + emit_dynamic_metadata: false)"; + + addReverseTunnelFilter(false, "/reverse_connections/request", "GET", validation_config); + initialize(); + + // This should fail since node_id won't match the complex format string. + std::string http_request = createHttpRequestWithRtHeaders( + "GET", "/reverse_connections/request", "simple-node", "test-cluster", "test-tenant"); + + IntegrationTcpClientPtr tcp_client = makeTcpConnection(lookupPort("listener_0")); + ASSERT_TRUE(tcp_client->write(http_request)); + tcp_client->waitForData("HTTP/1.1 403 Forbidden"); + tcp_client->waitForDisconnect(); + + // Ensure the validation_failed counter is updated. + test_server_->waitForCounterExists("reverse_tunnel.handshake.validation_failed"); + test_server_->waitForCounterGe("reverse_tunnel.handshake.validation_failed", 1); +} + +// Test validation passes when formatter returns empty and actual value is empty. +TEST_P(ReverseTunnelFilterIntegrationTest, ValidationWithBothValuesMatching) { + const std::string validation_config = R"( + validation: + node_id_format: "match-node" + cluster_id_format: "match-cluster")"; + + addReverseTunnelFilter(false, "/reverse_connections/request", "GET", validation_config); + initialize(); + + std::string http_request = createHttpRequestWithRtHeaders( + "GET", "/reverse_connections/request", "match-node", "match-cluster", "test-tenant"); + + IntegrationTcpClientPtr tcp_client = makeTcpConnection(lookupPort("listener_0")); + ASSERT_TRUE(tcp_client->write(http_request)); + tcp_client->waitForData("HTTP/1.1 200 OK"); + tcp_client->close(); + + test_server_->waitForCounterGe("reverse_tunnel.handshake.accepted", 1); +} + +// Test validation with FILTER_STATE formatter. +TEST_P(ReverseTunnelFilterIntegrationTest, ValidationWithFilterStateSuccess) { + // Set up filter state with expected values. + addSetFilterStateFilter("", "", ""); // Clear defaults. + + // Add filter state for expected values that the validator will check against. + config_helper_.addConfigModifier([](envoy::config::bootstrap::v3::Bootstrap& bootstrap) { + const std::string set_filter_state = R"EOF( +name: envoy.filters.network.set_filter_state +typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.set_filter_state.v3.Config + on_new_connection: + - object_key: expected_node_id + factory_key: envoy.string + format_string: + text_format_source: + inline_string: "validated-node" + - object_key: expected_cluster_id + factory_key: envoy.string + format_string: + text_format_source: + inline_string: "validated-cluster" +)EOF"; + + envoy::config::listener::v3::Filter filter; + TestUtility::loadFromYaml(set_filter_state, filter); + ASSERT_GT(bootstrap.mutable_static_resources()->listeners_size(), 0); + auto* listener = bootstrap.mutable_static_resources()->mutable_listeners(0); + + if (listener->filter_chains_size() == 0) { + listener->add_filter_chains(); + } else { + listener->mutable_filter_chains(0)->clear_filters(); + } + + listener->mutable_filter_chains(0)->add_filters()->Swap(&filter); + }); + + // Configure validation to use FILTER_STATE formatters with PLAIN specifier to get raw strings. + const std::string validation_config = R"( + validation: + node_id_format: "%FILTER_STATE(expected_node_id:PLAIN)%" + cluster_id_format: "%FILTER_STATE(expected_cluster_id:PLAIN)%")"; + + addReverseTunnelFilter(false, "/reverse_connections/request", "GET", validation_config); + initialize(); + + // Send request with headers matching filter state values. + std::string http_request = createHttpRequestWithRtHeaders( + "GET", "/reverse_connections/request", "validated-node", "validated-cluster", "test-tenant"); + + IntegrationTcpClientPtr tcp_client = makeTcpConnection(lookupPort("listener_0")); + ASSERT_TRUE(tcp_client->write(http_request)); + tcp_client->waitForData("HTTP/1.1 200 OK"); + tcp_client->close(); + + test_server_->waitForCounterGe("reverse_tunnel.handshake.accepted", 1); +} + +// Test validation with FILTER_STATE formatter. +TEST_P(ReverseTunnelFilterIntegrationTest, ValidationWithFilterStateFailure) { + // Set up filter state with expected values. + addSetFilterStateFilter("", "", ""); // Clear defaults. + + config_helper_.addConfigModifier([](envoy::config::bootstrap::v3::Bootstrap& bootstrap) { + const std::string set_filter_state = R"EOF( +name: envoy.filters.network.set_filter_state +typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.set_filter_state.v3.Config + on_new_connection: + - object_key: expected_node_id + factory_key: envoy.string + format_string: + text_format_source: + inline_string: "validated-node" + - object_key: expected_cluster_id + factory_key: envoy.string + format_string: + text_format_source: + inline_string: "validated-cluster" +)EOF"; + + envoy::config::listener::v3::Filter filter; + TestUtility::loadFromYaml(set_filter_state, filter); + ASSERT_GT(bootstrap.mutable_static_resources()->listeners_size(), 0); + auto* listener = bootstrap.mutable_static_resources()->mutable_listeners(0); + + if (listener->filter_chains_size() == 0) { + listener->add_filter_chains(); + } else { + listener->mutable_filter_chains(0)->clear_filters(); + } + + listener->mutable_filter_chains(0)->add_filters()->Swap(&filter); + }); + + // Configure validation to use FILTER_STATE formatters with PLAIN specifier to get raw strings. + const std::string validation_config = R"( + validation: + node_id_format: "%FILTER_STATE(expected_node_id:PLAIN)%" + cluster_id_format: "%FILTER_STATE(expected_cluster_id:PLAIN)%")"; + + addReverseTunnelFilter(false, "/reverse_connections/request", "GET", validation_config); + initialize(); + + // Send request with headers NOT matching filter state values. + std::string http_request = createHttpRequestWithRtHeaders( + "GET", "/reverse_connections/request", "wrong-node", "wrong-cluster", "test-tenant"); + + IntegrationTcpClientPtr tcp_client = makeTcpConnection(lookupPort("listener_0")); + ASSERT_TRUE(tcp_client->write(http_request)); + tcp_client->waitForData("HTTP/1.1 403 Forbidden"); + tcp_client->waitForDisconnect(); + + test_server_->waitForCounterGe("reverse_tunnel.handshake.validation_failed", 1); +} + +// Helper network filter to set dynamic metadata for testing. +class MetadataSetterFilter : public Network::ReadFilter { +public: + explicit MetadataSetterFilter(const std::string& namespace_key, + const std::map& metadata_values) + : namespace_key_(namespace_key), metadata_values_(metadata_values) {} + + Network::FilterStatus onData(Buffer::Instance&, bool) override { + return Network::FilterStatus::Continue; + } + + Network::FilterStatus onNewConnection() override { + // Set dynamic metadata. + if (!metadata_values_.empty()) { + Protobuf::Struct metadata_struct; + auto& fields = *metadata_struct.mutable_fields(); + + for (const auto& [key, value] : metadata_values_) { + fields[key].set_string_value(value); + } + + read_callbacks_->connection().streamInfo().setDynamicMetadata(namespace_key_, + metadata_struct); + } + + return Network::FilterStatus::Continue; + } + + void initializeReadFilterCallbacks(Network::ReadFilterCallbacks& callbacks) override { + read_callbacks_ = &callbacks; + } + +private: + Network::ReadFilterCallbacks* read_callbacks_{}; + const std::string namespace_key_; + const std::map metadata_values_; +}; + +// Config factory for MetadataSetterFilter. +class MetadataSetterFilterConfig : public Server::Configuration::NamedNetworkFilterConfigFactory { +public: + std::string name() const override { return "envoy.test.metadata_setter"; } + + ProtobufTypes::MessagePtr createEmptyConfigProto() override { + return std::make_unique(); + } + + absl::StatusOr + createFilterFactoryFromProto(const Protobuf::Message& proto, + Server::Configuration::FactoryContext&) override { + const auto& config = dynamic_cast(proto); + + // Extract namespace and metadata from config. + std::string namespace_key = "envoy.test.reverse_tunnel"; + std::map metadata_values; + + if (config.fields().contains("namespace")) { + namespace_key = config.fields().at("namespace").string_value(); + } + + if (config.fields().contains("metadata")) { + const auto& metadata_struct = config.fields().at("metadata").struct_value(); + for (const auto& [key, value] : metadata_struct.fields()) { + metadata_values[key] = value.string_value(); + } + } + + return [namespace_key, metadata_values](Network::FilterManager& filter_manager) { + filter_manager.addReadFilter( + std::make_shared(namespace_key, metadata_values)); + }; + } +}; + +// Register the metadata setter filter factory. +static Registry::RegisterFactory + register_metadata_setter_; + +// Test validation with DYNAMIC_METADATA formatter. +TEST_P(ReverseTunnelFilterIntegrationTest, ValidationWithDynamicMetadataSuccess) { + // Add metadata setter filter to populate dynamic metadata. + config_helper_.addConfigModifier([](envoy::config::bootstrap::v3::Bootstrap& bootstrap) { + // Create the Protobuf::Struct config programmatically. + Protobuf::Struct filter_config; + (*filter_config.mutable_fields())["namespace"].set_string_value("envoy.test.reverse_tunnel"); + + auto* metadata_struct = (*filter_config.mutable_fields())["metadata"].mutable_struct_value(); + (*metadata_struct->mutable_fields())["expected_node_id"].set_string_value( + "meta-validated-node"); + (*metadata_struct->mutable_fields())["expected_cluster_id"].set_string_value( + "meta-validated-cluster"); + + envoy::config::listener::v3::Filter filter; + filter.set_name("envoy.test.metadata_setter"); + filter.mutable_typed_config()->PackFrom(filter_config); + + ASSERT_GT(bootstrap.mutable_static_resources()->listeners_size(), 0); + auto* listener = bootstrap.mutable_static_resources()->mutable_listeners(0); + + if (listener->filter_chains_size() == 0) { + listener->add_filter_chains(); + } else { + listener->mutable_filter_chains(0)->clear_filters(); + } + + listener->mutable_filter_chains(0)->add_filters()->Swap(&filter); + }); + + // Configure validation to use DYNAMIC_METADATA formatters. + const std::string validation_config = R"( + validation: + node_id_format: "%DYNAMIC_METADATA(envoy.test.reverse_tunnel:expected_node_id)%" + cluster_id_format: "%DYNAMIC_METADATA(envoy.test.reverse_tunnel:expected_cluster_id)%")"; + + addReverseTunnelFilter(false, "/reverse_connections/request", "GET", validation_config); + initialize(); + + // Send request with headers matching dynamic metadata values. + std::string http_request = + createHttpRequestWithRtHeaders("GET", "/reverse_connections/request", "meta-validated-node", + "meta-validated-cluster", "test-tenant"); + + IntegrationTcpClientPtr tcp_client = makeTcpConnection(lookupPort("listener_0")); + ASSERT_TRUE(tcp_client->write(http_request)); + tcp_client->waitForData("HTTP/1.1 200 OK"); + tcp_client->close(); + + test_server_->waitForCounterGe("reverse_tunnel.handshake.accepted", 1); +} + +// Test validation with DYNAMIC_METADATA formatter. +TEST_P(ReverseTunnelFilterIntegrationTest, ValidationWithDynamicMetadataFailure) { + // Add metadata setter filter to populate dynamic metadata. + config_helper_.addConfigModifier([](envoy::config::bootstrap::v3::Bootstrap& bootstrap) { + // Create the Protobuf::Struct config programmatically. + Protobuf::Struct filter_config; + (*filter_config.mutable_fields())["namespace"].set_string_value("envoy.test.reverse_tunnel"); + + auto* metadata_struct = (*filter_config.mutable_fields())["metadata"].mutable_struct_value(); + (*metadata_struct->mutable_fields())["expected_node_id"].set_string_value( + "meta-validated-node"); + (*metadata_struct->mutable_fields())["expected_cluster_id"].set_string_value( + "meta-validated-cluster"); + + envoy::config::listener::v3::Filter filter; + filter.set_name("envoy.test.metadata_setter"); + filter.mutable_typed_config()->PackFrom(filter_config); + + ASSERT_GT(bootstrap.mutable_static_resources()->listeners_size(), 0); + auto* listener = bootstrap.mutable_static_resources()->mutable_listeners(0); + + if (listener->filter_chains_size() == 0) { + listener->add_filter_chains(); + } else { + listener->mutable_filter_chains(0)->clear_filters(); + } + + listener->mutable_filter_chains(0)->add_filters()->Swap(&filter); + }); + + // Configure validation to use DYNAMIC_METADATA formatters. + const std::string validation_config = R"( + validation: + node_id_format: "%DYNAMIC_METADATA(envoy.test.reverse_tunnel:expected_node_id)%" + cluster_id_format: "%DYNAMIC_METADATA(envoy.test.reverse_tunnel:expected_cluster_id)%")"; + + addReverseTunnelFilter(false, "/reverse_connections/request", "GET", validation_config); + initialize(); + + // Send request with headers NOT matching dynamic metadata values. + std::string http_request = createHttpRequestWithRtHeaders( + "GET", "/reverse_connections/request", "wrong-node", "wrong-cluster", "test-tenant"); + + IntegrationTcpClientPtr tcp_client = makeTcpConnection(lookupPort("listener_0")); + ASSERT_TRUE(tcp_client->write(http_request)); + tcp_client->waitForData("HTTP/1.1 403 Forbidden"); + tcp_client->waitForDisconnect(); + + test_server_->waitForCounterGe("reverse_tunnel.handshake.validation_failed", 1); +} + +// Test validation with mixed FILTER_STATE and DYNAMIC_METADATA formatters. +TEST_P(ReverseTunnelFilterIntegrationTest, ValidationWithMixedFormattersSuccess) { + // Set up filter state for node_id. + config_helper_.addConfigModifier([](envoy::config::bootstrap::v3::Bootstrap& bootstrap) { + const std::string set_filter_state = R"EOF( +name: envoy.filters.network.set_filter_state +typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.set_filter_state.v3.Config + on_new_connection: + - object_key: expected_node_id + factory_key: envoy.string + format_string: + text_format_source: + inline_string: "fs-node" +)EOF"; + + envoy::config::listener::v3::Filter filter; + TestUtility::loadFromYaml(set_filter_state, filter); + ASSERT_GT(bootstrap.mutable_static_resources()->listeners_size(), 0); + auto* listener = bootstrap.mutable_static_resources()->mutable_listeners(0); + + if (listener->filter_chains_size() == 0) { + listener->add_filter_chains(); + } else { + listener->mutable_filter_chains(0)->clear_filters(); + } + + listener->mutable_filter_chains(0)->add_filters()->Swap(&filter); + }); + + // Add metadata setter filter for cluster_id. + config_helper_.addConfigModifier([](envoy::config::bootstrap::v3::Bootstrap& bootstrap) { + // Create the Protobuf::Struct config programmatically. + Protobuf::Struct filter_config; + (*filter_config.mutable_fields())["namespace"].set_string_value("envoy.test.reverse_tunnel"); + + auto* metadata_struct = (*filter_config.mutable_fields())["metadata"].mutable_struct_value(); + (*metadata_struct->mutable_fields())["expected_cluster_id"].set_string_value("dm-cluster"); + + envoy::config::listener::v3::Filter filter; + filter.set_name("envoy.test.metadata_setter"); + filter.mutable_typed_config()->PackFrom(filter_config); + + ASSERT_GT(bootstrap.mutable_static_resources()->listeners_size(), 0); + auto* listener = bootstrap.mutable_static_resources()->mutable_listeners(0); + ASSERT_GT(listener->filter_chains_size(), 0); + + listener->mutable_filter_chains(0)->add_filters()->Swap(&filter); + }); + + // Configure validation to use both FILTER_STATE and DYNAMIC_METADATA formatters. + const std::string validation_config = R"( + validation: + node_id_format: "%FILTER_STATE(expected_node_id:PLAIN)%" + cluster_id_format: "%DYNAMIC_METADATA(envoy.test.reverse_tunnel:expected_cluster_id)%")"; + + addReverseTunnelFilter(false, "/reverse_connections/request", "GET", validation_config); + initialize(); + + // Send request with headers matching both filter state and dynamic metadata values. + std::string http_request = createHttpRequestWithRtHeaders("GET", "/reverse_connections/request", + "fs-node", "dm-cluster", "test-tenant"); + + IntegrationTcpClientPtr tcp_client = makeTcpConnection(lookupPort("listener_0")); + ASSERT_TRUE(tcp_client->write(http_request)); + tcp_client->waitForData("HTTP/1.1 200 OK"); + tcp_client->close(); + + test_server_->waitForCounterGe("reverse_tunnel.handshake.accepted", 1); +} + +// Test validation with mixed formatters. +TEST_P(ReverseTunnelFilterIntegrationTest, ValidationWithMixedFormattersNodeFailure) { + // Set up filter state for node_id. + config_helper_.addConfigModifier([](envoy::config::bootstrap::v3::Bootstrap& bootstrap) { + const std::string set_filter_state = R"EOF( +name: envoy.filters.network.set_filter_state +typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.set_filter_state.v3.Config + on_new_connection: + - object_key: expected_node_id + factory_key: envoy.string + format_string: + text_format_source: + inline_string: "fs-node" +)EOF"; + + envoy::config::listener::v3::Filter filter; + TestUtility::loadFromYaml(set_filter_state, filter); + ASSERT_GT(bootstrap.mutable_static_resources()->listeners_size(), 0); + auto* listener = bootstrap.mutable_static_resources()->mutable_listeners(0); + + if (listener->filter_chains_size() == 0) { + listener->add_filter_chains(); + } else { + listener->mutable_filter_chains(0)->clear_filters(); + } + + listener->mutable_filter_chains(0)->add_filters()->Swap(&filter); + }); + + // Add metadata setter filter for cluster_id. + config_helper_.addConfigModifier([](envoy::config::bootstrap::v3::Bootstrap& bootstrap) { + // Create the Protobuf::Struct config programmatically. + Protobuf::Struct filter_config; + (*filter_config.mutable_fields())["namespace"].set_string_value("envoy.test.reverse_tunnel"); + + auto* metadata_struct = (*filter_config.mutable_fields())["metadata"].mutable_struct_value(); + (*metadata_struct->mutable_fields())["expected_cluster_id"].set_string_value("dm-cluster"); + + envoy::config::listener::v3::Filter filter; + filter.set_name("envoy.test.metadata_setter"); + filter.mutable_typed_config()->PackFrom(filter_config); + + ASSERT_GT(bootstrap.mutable_static_resources()->listeners_size(), 0); + auto* listener = bootstrap.mutable_static_resources()->mutable_listeners(0); + ASSERT_GT(listener->filter_chains_size(), 0); + + listener->mutable_filter_chains(0)->add_filters()->Swap(&filter); + }); + + // Configure validation to use both FILTER_STATE and DYNAMIC_METADATA formatters. + const std::string validation_config = R"( + validation: + node_id_format: "%FILTER_STATE(expected_node_id:PLAIN)%" + cluster_id_format: "%DYNAMIC_METADATA(envoy.test.reverse_tunnel:expected_cluster_id)%")"; + + addReverseTunnelFilter(false, "/reverse_connections/request", "GET", validation_config); + initialize(); + + // Send request with wrong node_id but correct cluster_id. It should fail. + std::string http_request = createHttpRequestWithRtHeaders( + "GET", "/reverse_connections/request", "wrong-node", "dm-cluster", "test-tenant"); + + IntegrationTcpClientPtr tcp_client = makeTcpConnection(lookupPort("listener_0")); + ASSERT_TRUE(tcp_client->write(http_request)); + tcp_client->waitForData("HTTP/1.1 403 Forbidden"); + tcp_client->waitForDisconnect(); + + test_server_->waitForCounterGe("reverse_tunnel.handshake.validation_failed", 1); +} + +// Test validation with mixed formatters. +TEST_P(ReverseTunnelFilterIntegrationTest, ValidationWithMixedFormattersClusterFailure) { + // Set up filter state for node_id. + config_helper_.addConfigModifier([](envoy::config::bootstrap::v3::Bootstrap& bootstrap) { + const std::string set_filter_state = R"EOF( +name: envoy.filters.network.set_filter_state +typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.set_filter_state.v3.Config + on_new_connection: + - object_key: expected_node_id + factory_key: envoy.string + format_string: + text_format_source: + inline_string: "fs-node" +)EOF"; + + envoy::config::listener::v3::Filter filter; + TestUtility::loadFromYaml(set_filter_state, filter); + ASSERT_GT(bootstrap.mutable_static_resources()->listeners_size(), 0); + auto* listener = bootstrap.mutable_static_resources()->mutable_listeners(0); + + if (listener->filter_chains_size() == 0) { + listener->add_filter_chains(); + } else { + listener->mutable_filter_chains(0)->clear_filters(); + } + + listener->mutable_filter_chains(0)->add_filters()->Swap(&filter); + }); + + // Add metadata setter filter for cluster_id. + config_helper_.addConfigModifier([](envoy::config::bootstrap::v3::Bootstrap& bootstrap) { + // Create the Protobuf::Struct config programmatically. + Protobuf::Struct filter_config; + (*filter_config.mutable_fields())["namespace"].set_string_value("envoy.test.reverse_tunnel"); + + auto* metadata_struct = (*filter_config.mutable_fields())["metadata"].mutable_struct_value(); + (*metadata_struct->mutable_fields())["expected_cluster_id"].set_string_value("dm-cluster"); + + envoy::config::listener::v3::Filter filter; + filter.set_name("envoy.test.metadata_setter"); + filter.mutable_typed_config()->PackFrom(filter_config); + + ASSERT_GT(bootstrap.mutable_static_resources()->listeners_size(), 0); + auto* listener = bootstrap.mutable_static_resources()->mutable_listeners(0); + ASSERT_GT(listener->filter_chains_size(), 0); + + listener->mutable_filter_chains(0)->add_filters()->Swap(&filter); + }); + + // Configure validation to use both FILTER_STATE and DYNAMIC_METADATA formatters. + const std::string validation_config = R"( + validation: + node_id_format: "%FILTER_STATE(expected_node_id:PLAIN)%" + cluster_id_format: "%DYNAMIC_METADATA(envoy.test.reverse_tunnel:expected_cluster_id)%")"; + + addReverseTunnelFilter(false, "/reverse_connections/request", "GET", validation_config); + initialize(); + + // Send request with correct node_id but wrong cluster_id. It should fail. + std::string http_request = createHttpRequestWithRtHeaders( + "GET", "/reverse_connections/request", "fs-node", "wrong-cluster", "test-tenant"); + + IntegrationTcpClientPtr tcp_client = makeTcpConnection(lookupPort("listener_0")); + ASSERT_TRUE(tcp_client->write(http_request)); + tcp_client->waitForData("HTTP/1.1 403 Forbidden"); + tcp_client->waitForDisconnect(); + + test_server_->waitForCounterGe("reverse_tunnel.handshake.validation_failed", 1); +} + } // namespace } // namespace ReverseTunnel } // namespace NetworkFilters