diff --git a/CODEOWNERS b/CODEOWNERS index c8c3fa0e50a02..1ba4aff9cfa20 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -161,6 +161,8 @@ extensions/filters/http/oauth2 @rgs1 @derekargueta @snowp /*/extensions/filters/common/local_ratelimit @mattklein123 @rgs1 # HTTP Kill Request /*/extensions/filters/http/kill_request @qqustc @htuch +# HTTP Stateful Session +/*/extensions/filters/http/stateful_session @wbpcode @dio # Rate limit expression descriptor /*/extensions/rate_limit_descriptors/expr @kyessenov @lizan # hash input matcher @@ -195,6 +197,8 @@ extensions/filters/http/oauth2 @rgs1 @derekargueta @snowp /*/extensions/matching/input_matchers/ip @aguinet @snowp # Key Value store /*/extensions/key_value @alyssawilk @ryantheoptimist +# Stateful session +/*/extensions/http/stateful_session/cookie @wbpcode @dio # DNS Resolver /*/extensions/network/dns_resolver/cares @junr03 @yanavlasov /*/extensions/network/dns_resolver/apple @junr03 @yanavlasov diff --git a/api/BUILD b/api/BUILD index 5f5acd1e40552..1ba6e68a7ad32 100644 --- a/api/BUILD +++ b/api/BUILD @@ -171,6 +171,7 @@ proto_library( "//envoy/extensions/filters/http/rbac/v3:pkg", "//envoy/extensions/filters/http/router/v3:pkg", "//envoy/extensions/filters/http/set_metadata/v3:pkg", + "//envoy/extensions/filters/http/stateful_session/v3:pkg", "//envoy/extensions/filters/http/tap/v3:pkg", "//envoy/extensions/filters/http/wasm/v3:pkg", "//envoy/extensions/filters/listener/http_inspector/v3:pkg", @@ -211,6 +212,7 @@ proto_library( "//envoy/extensions/http/header_formatters/preserve_case/v3:pkg", "//envoy/extensions/http/original_ip_detection/custom_header/v3:pkg", "//envoy/extensions/http/original_ip_detection/xff/v3:pkg", + "//envoy/extensions/http/stateful_session/cookie/v3:pkg", "//envoy/extensions/internal_redirect/allow_listed_routes/v3:pkg", "//envoy/extensions/internal_redirect/previous_routes/v3:pkg", "//envoy/extensions/internal_redirect/safe_cross_scheme/v3:pkg", diff --git a/api/envoy/extensions/filters/http/stateful_session/v3/BUILD b/api/envoy/extensions/filters/http/stateful_session/v3/BUILD new file mode 100644 index 0000000000000..1c1a6f6b44235 --- /dev/null +++ b/api/envoy/extensions/filters/http/stateful_session/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/config/core/v3:pkg", + "@com_github_cncf_udpa//udpa/annotations:pkg", + ], +) diff --git a/api/envoy/extensions/filters/http/stateful_session/v3/stateful_session.proto b/api/envoy/extensions/filters/http/stateful_session/v3/stateful_session.proto new file mode 100644 index 0000000000000..54efd713e5e97 --- /dev/null +++ b/api/envoy/extensions/filters/http/stateful_session/v3/stateful_session.proto @@ -0,0 +1,40 @@ +syntax = "proto3"; + +package envoy.extensions.filters.http.stateful_session.v3; + +import "envoy/config/core/v3/extension.proto"; + +import "udpa/annotations/status.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.filters.http.stateful_session.v3"; +option java_outer_classname = "StatefulSessionProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/stateful_session/v3;stateful_sessionv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: Stateful session filter] +// Stateful session :ref:`configuration overview `. +// [#extension: envoy.filters.http.stateful_session] + +message StatefulSession { + // Specific implementation of session state. This session state will be used to store and + // get address of the upstream host to which the session is assigned. + // + // [#extension-category: envoy.http.stateful_session] + config.core.v3.TypedExtensionConfig session_state = 1 + [(validate.rules).message = {required: true}]; +} + +message StatefulSessionPerRoute { + oneof override { + option (validate.required) = true; + + // Disable the stateful session filter for this particular vhost or route. If disabled is + // specified in multiple per-filter-configs, the most specific one will be used. + bool disabled = 1 [(validate.rules).bool = {const: true}]; + + // Per-route stateful session configuration that can be served by RDS or static route table. + StatefulSession stateful_session = 2; + } +} diff --git a/api/envoy/extensions/http/stateful_session/cookie/v3/BUILD b/api/envoy/extensions/http/stateful_session/cookie/v3/BUILD new file mode 100644 index 0000000000000..7a3fc432b2f2c --- /dev/null +++ b/api/envoy/extensions/http/stateful_session/cookie/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/http/v3:pkg", + "@com_github_cncf_udpa//udpa/annotations:pkg", + ], +) diff --git a/api/envoy/extensions/http/stateful_session/cookie/v3/cookie.proto b/api/envoy/extensions/http/stateful_session/cookie/v3/cookie.proto new file mode 100644 index 0000000000000..403370e79d65f --- /dev/null +++ b/api/envoy/extensions/http/stateful_session/cookie/v3/cookie.proto @@ -0,0 +1,43 @@ +syntax = "proto3"; + +package envoy.extensions.http.stateful_session.cookie.v3; + +import "envoy/type/http/v3/cookie.proto"; + +import "udpa/annotations/status.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.extensions.http.stateful_session.cookie.v3"; +option java_outer_classname = "CookieProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/extensions/http/stateful_session/cookie/v3;cookiev3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: Cookie based stateful session extension] + +// This extension allows the session state to be tracked via cookies. +// +// This extension first encodes the address of the upstream host selected by the load balancer +// into a `set-cookie` response header with the :ref:`cookie configuration +// `. +// when new requests are incoming, this extension will try to parse the specific upstream host +// address by the cookie name. If the address parsed from the cookie corresponds to a valid +// upstream host, this upstream host will be selected first. See :ref:`stateful session filter +// `. +// +// For example, if the cookie name is set to `sticky-host`, envoy will prefer `1.2.3.4:80` +// as the upstream host when the request contains the following header: +// +// .. code-block:: none +// +// cookie: sticky-host="MS4yLjMuNDo4MA==" +// +// When processing the upstream response, if `1.2.3.4:80` is indeed the final choice the extension +// does nothing. If `1.2.3.4:80` is not the final choice, the new selected host will be used to +// update the cookie (via the `set-cookie` response header). +// +// [#extension: envoy.http.stateful_session.cookie] +message CookieBasedSessionState { + // The cookie configuration used to track session state. + type.http.v3.Cookie cookie = 1 [(validate.rules).message = {required: true}]; +} diff --git a/api/envoy/type/http/v3/cookie.proto b/api/envoy/type/http/v3/cookie.proto new file mode 100644 index 0000000000000..fba35eb86a3cb --- /dev/null +++ b/api/envoy/type/http/v3/cookie.proto @@ -0,0 +1,31 @@ +syntax = "proto3"; + +package envoy.type.http.v3; + +import "google/protobuf/duration.proto"; + +import "udpa/annotations/status.proto"; +import "validate/validate.proto"; + +option java_package = "io.envoyproxy.envoy.type.http.v3"; +option java_outer_classname = "CookieProto"; +option java_multiple_files = true; +option go_package = "github.com/envoyproxy/go-control-plane/envoy/type/http/v3;httpv3"; +option (udpa.annotations.file_status).package_version_status = ACTIVE; + +// [#protodoc-title: Http cookie API] + +// Cookie defines an API for obtaining or generating HTTP cookie. +message Cookie { + // The name that will be used to obtain cookie value from downstream HTTP request or generate + // new cookie for downstream. + string name = 1 [(validate.rules).string = {min_len: 1}]; + + // Duration of cookie. This will be used to set the expiry time of a new cookie when it is + // generated. Set this to 0 to use a session cookie. + google.protobuf.Duration ttl = 2 [(validate.rules).duration = {gte {}}]; + + // Path of cookie. This will be used to set the path of a new cookie when it is generated. + // If no path is specified here, no path will be set for the cookie. + string path = 3; +} diff --git a/api/versioning/BUILD b/api/versioning/BUILD index d1b865f254a9e..9b65ca4ef01a1 100644 --- a/api/versioning/BUILD +++ b/api/versioning/BUILD @@ -108,6 +108,7 @@ proto_library( "//envoy/extensions/filters/http/rbac/v3:pkg", "//envoy/extensions/filters/http/router/v3:pkg", "//envoy/extensions/filters/http/set_metadata/v3:pkg", + "//envoy/extensions/filters/http/stateful_session/v3:pkg", "//envoy/extensions/filters/http/tap/v3:pkg", "//envoy/extensions/filters/http/wasm/v3:pkg", "//envoy/extensions/filters/listener/http_inspector/v3:pkg", @@ -148,6 +149,7 @@ proto_library( "//envoy/extensions/http/header_formatters/preserve_case/v3:pkg", "//envoy/extensions/http/original_ip_detection/custom_header/v3:pkg", "//envoy/extensions/http/original_ip_detection/xff/v3:pkg", + "//envoy/extensions/http/stateful_session/cookie/v3:pkg", "//envoy/extensions/internal_redirect/allow_listed_routes/v3:pkg", "//envoy/extensions/internal_redirect/previous_routes/v3:pkg", "//envoy/extensions/internal_redirect/safe_cross_scheme/v3:pkg", diff --git a/docs/root/api-v3/config/config.rst b/docs/root/api-v3/config/config.rst index 605afc346a32d..220bac1d79619 100644 --- a/docs/root/api-v3/config/config.rst +++ b/docs/root/api-v3/config/config.rst @@ -30,6 +30,7 @@ Extensions request_id/request_id http/header_formatters http/original_ip_detection + http/stateful_session stat_sinks/stat_sinks quic/quic_extensions formatter/formatter diff --git a/docs/root/api-v3/config/http/stateful_session.rst b/docs/root/api-v3/config/http/stateful_session.rst new file mode 100644 index 0000000000000..70a60664172f2 --- /dev/null +++ b/docs/root/api-v3/config/http/stateful_session.rst @@ -0,0 +1,8 @@ +Stateful Session +===================== + +.. toctree:: + :glob: + :maxdepth: 2 + + ../../extensions/http/stateful_session/*/v3/* diff --git a/docs/root/api-v3/types/types.rst b/docs/root/api-v3/types/types.rst index 5fa6b7b45b0f7..a86620519ff87 100644 --- a/docs/root/api-v3/types/types.rst +++ b/docs/root/api-v3/types/types.rst @@ -13,6 +13,7 @@ Types ../type/v3/ratelimit_unit.proto ../type/v3/semantic_version.proto ../type/v3/token_bucket.proto + ../type/http/v3/cookie.proto ../type/http/v3/path_transformation.proto ../type/matcher/v3/metadata.proto ../type/matcher/v3/node.proto diff --git a/docs/root/configuration/http/http_filters/http_filters.rst b/docs/root/configuration/http/http_filters/http_filters.rst index dea25d116ed77..66cad575b57f5 100644 --- a/docs/root/configuration/http/http_filters/http_filters.rst +++ b/docs/root/configuration/http/http_filters/http_filters.rst @@ -46,3 +46,4 @@ HTTP filters sxg_filter tap_filter wasm_filter + stateful_session_filter diff --git a/docs/root/configuration/http/http_filters/stateful_session_filter.rst b/docs/root/configuration/http/http_filters/stateful_session_filter.rst new file mode 100644 index 0000000000000..8e36bb32d5789 --- /dev/null +++ b/docs/root/configuration/http/http_filters/stateful_session_filter.rst @@ -0,0 +1,83 @@ +.. _config_http_filters_stateful_session: + +Stateful session +================ + +Stateful session is an HTTP filter which overrides the upstream host based on extensible session state +and updates the session state based on the final selected upstream host. The override host will +eventually overwrites the load balancing result. This filter implements session stickiness without using +a hash-based load balancer. + +And by extending the session state, this filter also allows more flexible control over the results of +the load balancing. + +.. note:: + + Stateful sessions can result in imbalanced load across upstreams and allow external actors to direct + requests to specific upstream hosts. Operators should carefully consider the security and reliability + implications of stateful sessions before enabling this feature. + +Overview +-------- + +Session stickiness allows requests belonging to the same session to be consistently routed to a specific +upstream host. + +HTTP session stickiness in Envoy is generally achieved through hash-based load balancing. +The stickiness of hash-based sessions can be regarded as 'weak' since the upstream host may change when the +host set changes. This filter implements 'strong' stickiness. It is intended to handle the following cases: + +* The case where more stable session stickiness is required. For example, when a host is marked as degraded + but it is desirable to continue routing requests for existing sessions to that host. +* The case where a non hash-based load balancer (Random, Round Robin, etc.) is used and session stickiness + is still required. If stateful sessions are enabled in this case, requests for new sessions will be routed + to the corresponding upstream host based on the result of load balancing. Requests belonging to existing + sessions will be routed to the session's upstream host. + +Configuration +------------- + +* :ref:`v3 API reference ` +* This filter should be configured with the name *envoy.filters.http.stateful_session*. + +How it works +------------ + +The most important configuration for this filter is an :ref:`extensible session state +`. + +While processing the request, the stateful session filter will search for the corresponding session and +host based on the request. The results of the search will be used to influence the final load balancing +results. + +If no existing session is found, the filter will create a session to store the selected upstream host. +Please note that the session here is an abstract concept. The details of the storage are based on the +session state implementation. + +One example +___________ + +Currently, only :ref:`cookie-based session state +` is supported. +So let's take this as an example. + +.. code-block:: yaml + + name: envoy.filters.http.stateful_session + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.stateful_session.v3.StatefulSession + session_state: + name: envoy.http.stateful_session.cookie + typed_config: + "@type": type.googleapis.com/envoy.extensions.http.stateful_session.cookie.v3.CookieBasedSessionState + name: global-session-cookie + path: /path + ttl: 120s + + +In the above configuration, the cookie-based session state obtains the overridden host of the current session +from the cookie named `global-session-cookie` and if the corresponding host exists in the upstream cluster, the +request will be routed to that host. + +If there is no valid cookie, the load balancer will choose a new upstream host. When responding, the address +of the selected upstream host will be stored in the cookie named `global-session-cookie`. diff --git a/docs/root/version_history/current.rst b/docs/root/version_history/current.rst index 7f63aa2abb581..e0a8f2c363c7a 100644 --- a/docs/root/version_history/current.rst +++ b/docs/root/version_history/current.rst @@ -23,6 +23,7 @@ Minor Behavior Changes * listener: destroy per network filter chain stats when a network filter chain is removed during the listener in place update. * quic: add back the support for IETF draft 29 which is guarded via ``envoy.reloadable_features.FLAGS_quic_reloadable_flag_quic_disable_version_draft_29``. It is off by default so Envoy only supports RFCv1 without flipping this runtime guard explicitly. Draft 29 is not recommended for use. * router: take elapsed time into account when setting the x-envoy-expected-rq-timeout-ms header for retries, and never send a value that's longer than the request timeout. This behavioral change can be temporarily reverted by setting runtime guard ``envoy.reloadable_features.update_expected_rq_timeout_on_retry`` to false. +* stateful session http filter: added :ref:`stateful session http filter `. * stream_info: response code details with empty space characters (' ', '\t', '\f', '\v', '\n', '\r') is not accepted by the ``setResponseCodeDetails()`` API. * upstream: fixed a bug where auto_config didn't work for wrapped TLS sockets (e.g. if proxy proto were configured for TLS). diff --git a/envoy/http/BUILD b/envoy/http/BUILD index b9f7bfe3089b5..16563c5bbacc3 100644 --- a/envoy/http/BUILD +++ b/envoy/http/BUILD @@ -101,6 +101,7 @@ envoy_cc_library( "//envoy/ssl:connection_interface", "//envoy/stream_info:stream_info_interface", "//envoy/tracing:http_tracer_interface", + "//envoy/upstream:load_balancer_interface", "//source/common/common:scope_tracked_object_stack", ], ) @@ -182,3 +183,14 @@ envoy_cc_library( "//envoy/server:factory_context_interface", ], ) + +envoy_cc_library( + name = "stateful_session_interface", + hdrs = ["stateful_session.h"], + deps = [ + ":header_map_interface", + "//envoy/config:typed_config_interface", + "//envoy/server:factory_context_interface", + "//envoy/upstream:upstream_interface", + ], +) diff --git a/envoy/http/filter.h b/envoy/http/filter.h index c8fadf77f983a..3c460206979e8 100644 --- a/envoy/http/filter.h +++ b/envoy/http/filter.h @@ -16,6 +16,7 @@ #include "envoy/router/router.h" #include "envoy/ssl/connection.h" #include "envoy/tracing/http_tracer.h" +#include "envoy/upstream/load_balancer.h" #include "envoy/upstream/upstream.h" #include "source/common/common/scope_tracked_object_stack.h" @@ -610,6 +611,19 @@ class StreamDecoderFilterCallbacks : public virtual StreamFilterCallbacks { */ virtual void requestRouteConfigUpdate(RouteConfigUpdatedCallbackSharedPtr route_config_updated_cb) PURE; + + /** + * Set override host to be used by the upstream load balancing. If the target host exists in the + * host list of the routed cluster, the host should be selected first. + * @param host The override host address. + */ + virtual void setUpstreamOverrideHost(absl::string_view host) PURE; + + /** + * @return absl::optional optional overrride host for the upstream + * load balancing. + */ + virtual absl::optional upstreamOverrideHost() const PURE; }; /** diff --git a/envoy/http/stateful_session.h b/envoy/http/stateful_session.h new file mode 100644 index 0000000000000..c5da82f3aeeda --- /dev/null +++ b/envoy/http/stateful_session.h @@ -0,0 +1,82 @@ +#pragma once + +#include +#include + +#include "envoy/common/pure.h" +#include "envoy/config/typed_config.h" +#include "envoy/http/header_map.h" +#include "envoy/server/factory_context.h" +#include "envoy/upstream/upstream.h" + +namespace Envoy { +namespace Http { + +/** + * Interface class for session state. Session state is used to get address of upstream host + * assigned to the session. + */ +class SessionState { +public: + virtual ~SessionState() = default; + + /** + * Get address of upstream host that the current session stuck on. + * + * @return absl::optional optional upstream address. If there is no available + * session or no available address, absl::nullopt will be returned. + */ + virtual absl::optional upstreamAddress() const PURE; + + /** + * Called when a request is completed to update the session state. + * + * @param host the upstream host that was finally selected. + * @param headers the response headers. + */ + virtual void onUpdate(const Upstream::HostDescription& host, ResponseHeaderMap& headers) PURE; +}; + +using SessionStatePtr = std::unique_ptr; + +/** + * Interface class for creating session state from request headers. + */ +class SessionStateFactory { +public: + virtual ~SessionStateFactory() = default; + + /** + * Create session state from request headers. + * + * @param headers request headers. + */ + virtual SessionStatePtr create(const RequestHeaderMap& headers) const PURE; +}; + +using SessionStateFactorySharedPtr = std::shared_ptr; + +/* + * Extension configuration for session state factory. + */ +class SessionStateFactoryConfig : public Envoy::Config::TypedFactory { +public: + ~SessionStateFactoryConfig() override = default; + + /** + * Creates a particular session state factory implementation. + * + * @param config supplies the configuration for the session state factory extension. + * @return SessionStateFactorySharedPtr the session state factory. + */ + virtual SessionStateFactorySharedPtr + createSessionStateFactory(const Protobuf::Message& config, + Server::Configuration::CommonFactoryContext& context) PURE; + + std::string category() const override { return "envoy.http.stateful_session"; } +}; + +using SessionStateFactoryConfigPtr = std::unique_ptr; + +} // namespace Http +} // namespace Envoy diff --git a/source/common/http/async_client_impl.h b/source/common/http/async_client_impl.h index 86c00a7e2a6ed..6888ddaa2db7f 100644 --- a/source/common/http/async_client_impl.h +++ b/source/common/http/async_client_impl.h @@ -399,6 +399,8 @@ class AsyncStreamImpl : public AsyncClient::Stream, Network::Socket::OptionsSharedPtr getUpstreamSocketOptions() const override { return {}; } void requestRouteConfigUpdate(Http::RouteConfigUpdatedCallbackSharedPtr) override {} void resetIdleTimer() override {} + void setUpstreamOverrideHost(absl::string_view) override {} + absl::optional upstreamOverrideHost() const override { return {}; } // ScopeTrackedObject void dumpState(std::ostream& os, int indent_level) const override { diff --git a/source/common/http/filter_manager.cc b/source/common/http/filter_manager.cc index 2864da775867b..c91cc4feca899 100644 --- a/source/common/http/filter_manager.cc +++ b/source/common/http/filter_manager.cc @@ -1665,5 +1665,13 @@ Buffer::BufferMemoryAccountSharedPtr ActiveStreamDecoderFilter::account() const return parent_.account(); } +void ActiveStreamDecoderFilter::setUpstreamOverrideHost(absl::string_view host) { + parent_.upstream_override_host_.emplace(std::move(host)); +} + +absl::optional ActiveStreamDecoderFilter::upstreamOverrideHost() const { + return parent_.upstream_override_host_; +} + } // namespace Http } // namespace Envoy diff --git a/source/common/http/filter_manager.h b/source/common/http/filter_manager.h index e7cff1b731e8e..692b5886a35b0 100644 --- a/source/common/http/filter_manager.h +++ b/source/common/http/filter_manager.h @@ -281,6 +281,8 @@ struct ActiveStreamDecoderFilter : public ActiveStreamFilterBase, Network::Socket::OptionsSharedPtr getUpstreamSocketOptions() const override; Buffer::BufferMemoryAccountSharedPtr account() const override; + void setUpstreamOverrideHost(absl::string_view host) override; + absl::optional upstreamOverrideHost() const override; // Each decoder filter instance checks if the request passed to the filter is gRPC // so that we can issue gRPC local responses to gRPC requests. Filter's decodeHeaders() @@ -1027,6 +1029,7 @@ class FilterManager : public ScopeTrackedObject, std::list watermark_callbacks_; Network::Socket::OptionsSharedPtr upstream_options_ = std::make_shared(); + absl::optional upstream_override_host_; FilterChainFactory& filter_chain_factory_; const LocalReply::LocalReply& local_reply_; diff --git a/source/common/router/BUILD b/source/common/router/BUILD index 90ac3acb21d9f..ad22d3cc9017e 100644 --- a/source/common/router/BUILD +++ b/source/common/router/BUILD @@ -293,6 +293,7 @@ envoy_cc_library( "//envoy/http:codes_interface", "//envoy/http:conn_pool_interface", "//envoy/http:filter_interface", + "//envoy/http:stateful_session_interface", "//envoy/local_info:local_info_interface", "//envoy/router:shadow_writer_interface", "//envoy/runtime:runtime_interface", @@ -313,6 +314,7 @@ envoy_cc_library( "//source/common/common:minimal_logger_lib", "//source/common/common:scope_tracker", "//source/common/common:utility_lib", + "//source/common/config:utility_lib", "//source/common/grpc:common_lib", "//source/common/http:codes_lib", "//source/common/http:header_map_lib", diff --git a/source/common/router/router.h b/source/common/router/router.h index fe5050f74fa22..e2c3dccdefea8 100644 --- a/source/common/router/router.h +++ b/source/common/router/router.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include "envoy/common/random_generator.h" @@ -11,6 +12,7 @@ #include "envoy/http/codec.h" #include "envoy/http/codes.h" #include "envoy/http/filter.h" +#include "envoy/http/stateful_session.h" #include "envoy/local_info/local_info.h" #include "envoy/router/shadow_writer.h" #include "envoy/runtime/runtime.h" @@ -26,6 +28,7 @@ #include "source/common/common/hex.h" #include "source/common/common/linked_object.h" #include "source/common/common/logger.h" +#include "source/common/config/utility.h" #include "source/common/config/well_known_names.h" #include "source/common/http/utility.h" #include "source/common/router/config_impl.h" @@ -424,6 +427,20 @@ class Filter : Logger::Loggable, return transport_socket_options_; } + absl::optional overrideHostToSelect() const override { + if (is_retry_) { + return {}; + } + + auto override_host = callbacks_->upstreamOverrideHost(); + if (override_host.has_value()) { + // TODO(wbpcode): Currently we need to provide additional expected host status to the load + // balancer. This should be resolved after the `overrideHostToSelect()` refactoring. + return std::make_pair(std::string(override_host.value()), ~static_cast(0)); + } + return {}; + } + /** * Set a computed cookie to be sent with the downstream headers. * @param key supplies the size of the cookie diff --git a/source/extensions/extensions_build_config.bzl b/source/extensions/extensions_build_config.bzl index af6c536084391..57d6fd5eacdd6 100644 --- a/source/extensions/extensions_build_config.bzl +++ b/source/extensions/extensions_build_config.bzl @@ -106,6 +106,7 @@ EXTENSIONS = { "envoy.filters.http.set_metadata": "//source/extensions/filters/http/set_metadata:config", "envoy.filters.http.tap": "//source/extensions/filters/http/tap:config", "envoy.filters.http.wasm": "//source/extensions/filters/http/wasm:config", + "envoy.filters.http.stateful_session": "//source/extensions/filters/http/stateful_session:config", # # Listener filters @@ -281,6 +282,12 @@ EXTENSIONS = { "envoy.http.original_ip_detection.custom_header": "//source/extensions/http/original_ip_detection/custom_header:config", "envoy.http.original_ip_detection.xff": "//source/extensions/http/original_ip_detection/xff:config", + # + # Stateful session + # + + "envoy.http.stateful_session.cookie": "//source/extensions/http/stateful_session/cookie:config", + # # Quic extensions # diff --git a/source/extensions/extensions_metadata.yaml b/source/extensions/extensions_metadata.yaml index 38805cae6376c..480bae00374bc 100644 --- a/source/extensions/extensions_metadata.yaml +++ b/source/extensions/extensions_metadata.yaml @@ -278,6 +278,11 @@ envoy.filters.http.wasm: - envoy.filters.http security_posture: unknown status: alpha +envoy.filters.http.stateful_session: + categories: + - envoy.filters.http + security_posture: unknown + status: alpha envoy.filters.listener.http_inspector: categories: - envoy.filters.listener @@ -735,3 +740,8 @@ envoy.rbac.matchers.upstream_ip_port: - envoy.rbac.matchers security_posture: unknown status: alpha +envoy.http.stateful_session.cookie: + categories: + - envoy.http.stateful_session + security_posture: unknown + status: alpha diff --git a/source/extensions/filters/http/stateful_session/BUILD b/source/extensions/filters/http/stateful_session/BUILD new file mode 100644 index 0000000000000..7ae11125ae579 --- /dev/null +++ b/source/extensions/filters/http/stateful_session/BUILD @@ -0,0 +1,41 @@ +load( + "//bazel:envoy_build_system.bzl", + "envoy_cc_extension", + "envoy_cc_library", + "envoy_extension_package", +) + +licenses(["notice"]) # Apache 2 + +envoy_extension_package() + +envoy_cc_library( + name = "stateful_session_lib", + srcs = ["stateful_session.cc"], + hdrs = ["stateful_session.h"], + deps = [ + "//envoy/http:stateful_session_interface", + "//envoy/server:filter_config_interface", + "//envoy/upstream:load_balancer_interface", + "//source/common/config:utility_lib", + "//source/common/http:utility_lib", + "//source/common/protobuf:utility_lib", + "//source/common/upstream:load_balancer_lib", + "//source/extensions/filters/http:well_known_names", + "//source/extensions/filters/http/common:pass_through_filter_lib", + "@envoy_api//envoy/extensions/filters/http/stateful_session/v3:pkg_cc_proto", + ], +) + +envoy_cc_extension( + name = "config", + srcs = ["config.cc"], + hdrs = ["config.h"], + deps = [ + "//envoy/registry", + "//source/common/protobuf:utility_lib", + "//source/extensions/filters/http/common:factory_base_lib", + "//source/extensions/filters/http/stateful_session:stateful_session_lib", + "@envoy_api//envoy/extensions/filters/http/stateful_session/v3:pkg_cc_proto", + ], +) diff --git a/source/extensions/filters/http/stateful_session/config.cc b/source/extensions/filters/http/stateful_session/config.cc new file mode 100644 index 0000000000000..fad6235e8d9f5 --- /dev/null +++ b/source/extensions/filters/http/stateful_session/config.cc @@ -0,0 +1,35 @@ +#include "source/extensions/filters/http/stateful_session/config.h" + +#include + +#include "envoy/registry/registry.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace StatefulSession { + +Http::FilterFactoryCb StatefulSessionFactoryConfig::createFilterFactoryFromProtoTyped( + const ProtoConfig& proto_config, const std::string&, + Server::Configuration::FactoryContext& context) { + auto filter_config(std::make_shared(proto_config, context)); + + return [filter_config](Http::FilterChainFactoryCallbacks& callbacks) -> void { + callbacks.addStreamFilter( + Http::StreamFilterSharedPtr{new StatefulSession(filter_config.get())}); + }; +} + +Router::RouteSpecificFilterConfigConstSharedPtr +StatefulSessionFactoryConfig::createRouteSpecificFilterConfigTyped( + const PerRouteProtoConfig& proto_config, Server::Configuration::ServerFactoryContext& context, + ProtobufMessage::ValidationVisitor&) { + return std::make_shared(proto_config, context); +} + +REGISTER_FACTORY(StatefulSessionFactoryConfig, Server::Configuration::NamedHttpFilterConfigFactory); + +} // namespace StatefulSession +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/stateful_session/config.h b/source/extensions/filters/http/stateful_session/config.h new file mode 100644 index 0000000000000..3d120c6a7ba36 --- /dev/null +++ b/source/extensions/filters/http/stateful_session/config.h @@ -0,0 +1,35 @@ +#pragma once + +#include "envoy/extensions/filters/http/stateful_session/v3/stateful_session.pb.h" +#include "envoy/extensions/filters/http/stateful_session/v3/stateful_session.pb.validate.h" + +#include "source/extensions/filters/http/common/factory_base.h" +#include "source/extensions/filters/http/stateful_session/stateful_session.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace StatefulSession { + +/** + * Config registration for the stateful session filter. @see NamedHttpFilterConfigFactory. + */ +class StatefulSessionFactoryConfig : public Common::FactoryBase { +public: + StatefulSessionFactoryConfig() : FactoryBase("envoy.filters.http.stateful_session") {} + +private: + Http::FilterFactoryCb + createFilterFactoryFromProtoTyped(const ProtoConfig& proto_config, + const std::string& stats_prefix, + Server::Configuration::FactoryContext& context) override; + Router::RouteSpecificFilterConfigConstSharedPtr + createRouteSpecificFilterConfigTyped(const PerRouteProtoConfig& proto_config, + Server::Configuration::ServerFactoryContext&, + ProtobufMessage::ValidationVisitor&) override; +}; + +} // namespace StatefulSession +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/stateful_session/stateful_session.cc b/source/extensions/filters/http/stateful_session/stateful_session.cc new file mode 100644 index 0000000000000..4e99d7fa3700a --- /dev/null +++ b/source/extensions/filters/http/stateful_session/stateful_session.cc @@ -0,0 +1,74 @@ +#include "source/extensions/filters/http/stateful_session/stateful_session.h" + +#include +#include + +#include "source/common/config/utility.h" +#include "source/common/http/utility.h" +#include "source/common/upstream/load_balancer_impl.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace StatefulSession { + +StatefulSessionConfig::StatefulSessionConfig(const ProtoConfig& config, + Server::Configuration::CommonFactoryContext& context) { + auto& factory = + Envoy::Config::Utility::getAndCheckFactoryByName( + config.session_state().name()); + + auto typed_config = Envoy::Config::Utility::translateAnyToFactoryConfig( + config.session_state().typed_config(), context.messageValidationVisitor(), factory); + + factory_ = factory.createSessionStateFactory(*typed_config, context); +} + +PerRouteStatefulSession::PerRouteStatefulSession( + const PerRouteProtoConfig& config, Server::Configuration::CommonFactoryContext& context) { + if (config.override_case() == PerRouteProtoConfig::kDisabled) { + disabled_ = true; + return; + } + config_ = std::make_shared(config.stateful_session(), context); +} + +Http::FilterHeadersStatus StatefulSession::decodeHeaders(Http::RequestHeaderMap& headers, bool) { + const StatefulSessionConfig* config = config_; + auto route_config = Http::Utility::resolveMostSpecificPerFilterConfig( + "envoy.filters.http.stateful_session", decoder_callbacks_->route()); + + if (route_config != nullptr) { + if (route_config->disabled()) { + return Http::FilterHeadersStatus::Continue; + } + config = route_config->statefuleSessionConfig(); + } + session_state_ = config->createSessionState(headers); + + if (auto upstream_address = session_state_->upstreamAddress(); upstream_address.has_value()) { + decoder_callbacks_->setUpstreamOverrideHost(upstream_address.value()); + } + return Http::FilterHeadersStatus::Continue; +} + +Http::FilterHeadersStatus StatefulSession::encodeHeaders(Http::ResponseHeaderMap& headers, bool) { + if (session_state_ == nullptr) { + return Http::FilterHeadersStatus::Continue; + } + + if (auto upstream_info = encoder_callbacks_->streamInfo().upstreamInfo(); + upstream_info != nullptr) { + auto host = upstream_info->upstreamHost(); + if (host != nullptr) { + session_state_->onUpdate(*host, headers); + } + } + + return Http::FilterHeadersStatus::Continue; +} + +} // namespace StatefulSession +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/filters/http/stateful_session/stateful_session.h b/source/extensions/filters/http/stateful_session/stateful_session.h new file mode 100644 index 0000000000000..e0d9e2a26b6d8 --- /dev/null +++ b/source/extensions/filters/http/stateful_session/stateful_session.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include +#include + +#include "envoy/extensions/filters/http/stateful_session/v3/stateful_session.pb.h" +#include "envoy/http/stateful_session.h" +#include "envoy/upstream/load_balancer.h" + +#include "source/common/common/logger.h" +#include "source/extensions/filters/http/common/pass_through_filter.h" + +#include "absl/strings/string_view.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace StatefulSession { + +using ProtoConfig = envoy::extensions::filters::http::stateful_session::v3::StatefulSession; +using PerRouteProtoConfig = + envoy::extensions::filters::http::stateful_session::v3::StatefulSessionPerRoute; + +class StatefulSessionConfig { +public: + StatefulSessionConfig(const ProtoConfig& config, + Server::Configuration::CommonFactoryContext& context); + + Http::SessionStatePtr createSessionState(const Http::RequestHeaderMap& headers) const { + ASSERT(factory_ != nullptr); + return factory_->create(headers); + } + +private: + Http::SessionStateFactorySharedPtr factory_; +}; +using StatefulSessionConfigSharedPtr = std::shared_ptr; + +class PerRouteStatefulSession : public Router::RouteSpecificFilterConfig { +public: + PerRouteStatefulSession(const PerRouteProtoConfig& config, + Server::Configuration::CommonFactoryContext& context); + + bool disabled() const { return disabled_; } + StatefulSessionConfig* statefuleSessionConfig() const { return config_.get(); } + +private: + bool disabled_{}; + StatefulSessionConfigSharedPtr config_; +}; +using PerRouteStatefulSessionConfigSharedPtr = std::shared_ptr; + +class StatefulSession : public Http::PassThroughFilter, + public Logger::Loggable { +public: + StatefulSession(const StatefulSessionConfig* config) : config_(config) {} + + // Http::StreamDecoderFilter + Http::FilterHeadersStatus decodeHeaders(Http::RequestHeaderMap& headers, bool) override; + + // Http::StreamEncoderFilter + Http::FilterHeadersStatus encodeHeaders(Http::ResponseHeaderMap& headers, bool) override; + + Http::SessionStatePtr& sessionStateForTest() { return session_state_; } + +private: + Http::SessionStatePtr session_state_; + + const StatefulSessionConfig* config_{nullptr}; +}; + +} // namespace StatefulSession +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/stateful_session/cookie/BUILD b/source/extensions/http/stateful_session/cookie/BUILD new file mode 100644 index 0000000000000..0ed5f2b95e160 --- /dev/null +++ b/source/extensions/http/stateful_session/cookie/BUILD @@ -0,0 +1,40 @@ +load( + "//bazel:envoy_build_system.bzl", + "envoy_cc_extension", + "envoy_cc_library", + "envoy_extension_package", +) + +licenses(["notice"]) # Apache 2 + +envoy_extension_package() + +envoy_cc_library( + name = "cookie_lib", + srcs = ["cookie.cc"], + hdrs = ["cookie.h"], + # This extension is core code. + visibility = ["//visibility:public"], + deps = [ + "//envoy/http:stateful_session_interface", + "//source/common/common:base64_lib", + "//source/common/http:headers_lib", + "//source/common/http:utility_lib", + "@envoy_api//envoy/extensions/http/stateful_session/cookie/v3:pkg_cc_proto", + ], +) + +envoy_cc_extension( + name = "config", + srcs = ["config.cc"], + hdrs = ["config.h"], + # This extension is core code. + visibility = ["//visibility:public"], + deps = [ + ":cookie_lib", + "//envoy/http:stateful_session_interface", + "//envoy/registry", + "//source/common/config:utility_lib", + "@envoy_api//envoy/extensions/http/stateful_session/cookie/v3:pkg_cc_proto", + ], +) diff --git a/source/extensions/http/stateful_session/cookie/config.cc b/source/extensions/http/stateful_session/cookie/config.cc new file mode 100644 index 0000000000000..39a0348d22708 --- /dev/null +++ b/source/extensions/http/stateful_session/cookie/config.cc @@ -0,0 +1,26 @@ +#include "source/extensions/http/stateful_session/cookie/config.h" + +#include "source/common/config/utility.h" + +namespace Envoy { +namespace Extensions { +namespace Http { +namespace StatefulSession { +namespace Cookie { + +Envoy::Http::SessionStateFactorySharedPtr +CookieBasedSessionStateFactoryConfig::createSessionStateFactory( + const Protobuf::Message& config, Server::Configuration::CommonFactoryContext& context) { + + const auto& proto_config = MessageUtil::downcastAndValidate( + config, context.messageValidationVisitor()); + return std::make_shared(proto_config); +} + +REGISTER_FACTORY(CookieBasedSessionStateFactoryConfig, Envoy::Http::SessionStateFactoryConfig); + +} // namespace Cookie +} // namespace StatefulSession +} // namespace Http +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/stateful_session/cookie/config.h b/source/extensions/http/stateful_session/cookie/config.h new file mode 100644 index 0000000000000..81c2d0b9abed6 --- /dev/null +++ b/source/extensions/http/stateful_session/cookie/config.h @@ -0,0 +1,31 @@ +#pragma once + +#include "envoy/extensions/http/stateful_session/cookie/v3/cookie.pb.validate.h" + +#include "source/extensions/http/stateful_session/cookie/cookie.h" + +namespace Envoy { +namespace Extensions { +namespace Http { +namespace StatefulSession { +namespace Cookie { + +class CookieBasedSessionStateFactoryConfig : public Envoy::Http::SessionStateFactoryConfig { +public: + Envoy::Http::SessionStateFactorySharedPtr + createSessionStateFactory(const Protobuf::Message& config, + Server::Configuration::CommonFactoryContext& context) override; + + ProtobufTypes::MessagePtr createEmptyConfigProto() override { + return std::make_unique< + envoy::extensions::http::stateful_session::cookie::v3::CookieBasedSessionState>(); + } + + std::string name() const override { return "envoy.http.stateful_session.cookie"; } +}; + +} // namespace Cookie +} // namespace StatefulSession +} // namespace Http +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/stateful_session/cookie/cookie.cc b/source/extensions/http/stateful_session/cookie/cookie.cc new file mode 100644 index 0000000000000..4fb6737bd4314 --- /dev/null +++ b/source/extensions/http/stateful_session/cookie/cookie.cc @@ -0,0 +1,35 @@ +#include "source/extensions/http/stateful_session/cookie/cookie.h" + +#include "source/common/http/headers.h" + +namespace Envoy { +namespace Extensions { +namespace Http { +namespace StatefulSession { +namespace Cookie { + +void CookieBasedSessionStateFactory::SessionStateImpl::onUpdate( + const Upstream::HostDescription& host, Envoy::Http::ResponseHeaderMap& headers) { + absl::string_view host_address = host.address()->asStringView(); + if (!upstream_address_.has_value() || host_address != upstream_address_.value()) { + const std::string encoded_address = + Envoy::Base64::encode(host_address.data(), host_address.length()); + headers.addReferenceKey(Envoy::Http::Headers::get().SetCookie, + factory_.makeSetCookie(encoded_address)); + } +} + +CookieBasedSessionStateFactory::CookieBasedSessionStateFactory( + const CookieBasedSessionStateProto& config) + : name_(config.cookie().name()), ttl_(config.cookie().ttl().seconds()), + path_(config.cookie().path()) { + if (name_.empty()) { + throw EnvoyException("Cookie key cannot be empty for cookie based stateful sessions"); + } +} + +} // namespace Cookie +} // namespace StatefulSession +} // namespace Http +} // namespace Extensions +} // namespace Envoy diff --git a/source/extensions/http/stateful_session/cookie/cookie.h b/source/extensions/http/stateful_session/cookie/cookie.h new file mode 100644 index 0000000000000..31fabcdc034af --- /dev/null +++ b/source/extensions/http/stateful_session/cookie/cookie.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include + +#include "envoy/extensions/http/stateful_session/cookie/v3/cookie.pb.h" +#include "envoy/http/stateful_session.h" + +#include "source/common/common/base64.h" +#include "source/common/http/utility.h" + +namespace Envoy { +namespace Extensions { +namespace Http { +namespace StatefulSession { +namespace Cookie { + +using CookieBasedSessionStateProto = + envoy::extensions::http::stateful_session::cookie::v3::CookieBasedSessionState; + +class CookieBasedSessionStateFactory : public Envoy::Http::SessionStateFactory { +public: + class SessionStateImpl : public Envoy::Http::SessionState { + public: + SessionStateImpl(absl::optional address, + const CookieBasedSessionStateFactory& factory) + : upstream_address_(std::move(address)), factory_(factory) {} + + absl::optional upstreamAddress() const override { return upstream_address_; } + void onUpdate(const Upstream::HostDescription& host, + Envoy::Http::ResponseHeaderMap& headers) override; + + private: + absl::optional upstream_address_; + const CookieBasedSessionStateFactory& factory_; + }; + + CookieBasedSessionStateFactory(const CookieBasedSessionStateProto& config); + + Envoy::Http::SessionStatePtr create(const Envoy::Http::RequestHeaderMap& headers) const override { + return std::make_unique(parseAddress(headers), *this); + } + +private: + absl::optional parseAddress(const Envoy::Http::RequestHeaderMap& headers) const { + const std::string cookie_value = Envoy::Http::Utility::parseCookieValue(headers, name_); + std::string address = Envoy::Base64::decode(cookie_value); + + return !address.empty() ? absl::make_optional(std::move(address)) : absl::nullopt; + } + + std::string makeSetCookie(const std::string& address) const { + return Envoy::Http::Utility::makeSetCookieValue(name_, address, path_, ttl_, true); + } + + const std::string name_; + const std::chrono::seconds ttl_; + const std::string path_; +}; + +} // namespace Cookie +} // namespace StatefulSession +} // namespace Http +} // namespace Extensions +} // namespace Envoy diff --git a/test/common/http/filter_manager_test.cc b/test/common/http/filter_manager_test.cc index ebd3d1128455c..ac98bdbef9de9 100644 --- a/test/common/http/filter_manager_test.cc +++ b/test/common/http/filter_manager_test.cc @@ -569,6 +569,25 @@ TEST_F(FilterManagerTest, ResetIdleTimer) { filter_manager_->destroyFilters(); } +TEST_F(FilterManagerTest, SetAndGetUpstreamOverrideHost) { + initialize(); + + std::shared_ptr decoder_filter(new NiceMock()); + + EXPECT_CALL(filter_factory_, createFilterChain(_)) + .WillRepeatedly(Invoke([&](FilterChainFactoryCallbacks& callbacks) -> void { + callbacks.addStreamDecoderFilter(decoder_filter); + })); + filter_manager_->createFilterChain(); + + decoder_filter->callbacks_->setUpstreamOverrideHost("1.2.3.4"); + + auto override_host = decoder_filter->callbacks_->upstreamOverrideHost(); + EXPECT_EQ(override_host.value(), "1.2.3.4"); + + filter_manager_->destroyFilters(); +}; + } // namespace } // namespace Http } // namespace Envoy diff --git a/test/common/router/router_test.cc b/test/common/router/router_test.cc index a7c17701573ce..9b8025f8ab5d3 100644 --- a/test/common/router/router_test.cc +++ b/test/common/router/router_test.cc @@ -6593,5 +6593,78 @@ TEST(RouterFilterUtilityTest, SetTimeoutHeaders) { } } +// Test the case that request with upstream override host. +TEST_F(RouterTest, RequestWithUpstreamOverrideHost) { + NiceMock encoder_for_first_reqeust; + Http::ResponseDecoder* response_decoder = nullptr; + EXPECT_CALL(cm_.thread_local_cluster_.conn_pool_, newStream(_, _)) + .WillOnce(Invoke( + [&](Http::ResponseDecoder& decoder, + Http::ConnectionPool::Callbacks& callbacks) -> Http::ConnectionPool::Cancellable* { + response_decoder = &decoder; + callbacks.onPoolReady(encoder_for_first_reqeust, + cm_.thread_local_cluster_.conn_pool_.host_, upstream_stream_info_, + Http::Protocol::Http10); + return nullptr; + })); + expectResponseTimerCreate(); + + // Simulate the load balancer to call the `overrideHostToSelect`. When `overrideHostToSelect` of + // `LoadBalancerContext` is called, `upstreamOverrideHost` of StreamDecoderFilterCallbacks will be + // called to get address of upstream host that should be selected first. + EXPECT_CALL(callbacks_, upstreamOverrideHost()) + .WillOnce(Return(absl::make_optional("1.2.3.4"))); + + auto override_host = router_.overrideHostToSelect(); + EXPECT_EQ("1.2.3.4", override_host->first); + EXPECT_EQ(~static_cast(0), override_host->second); + + Http::TestRequestHeaderMapImpl headers{{"x-envoy-retry-on", "5xx"}, {"x-envoy-internal", "true"}}; + HttpTestUtility::addDefaultHeaders(headers); + + // Simulate the normal first request. + router_.decodeHeaders(headers, true); + + // Mock response with status 503. + router_.retry_state_->expectHeadersRetry(); + Http::ResponseHeaderMapPtr response_headers_503( + new Http::TestResponseHeaderMapImpl{{":status", "503"}}); + ASSERT(response_decoder != nullptr); + // NOLINTNEXTLINE: Silence null pointer access warning + response_decoder->decodeHeaders(std::move(response_headers_503), true); + + // Kick off a new request. + NiceMock encoder_for_retry_request; + EXPECT_CALL(cm_.thread_local_cluster_.conn_pool_, newStream(_, _)) + .WillOnce(Invoke( + [&](Http::ResponseDecoder& decoder, + Http::ConnectionPool::Callbacks& callbacks) -> Http::ConnectionPool::Cancellable* { + response_decoder = &decoder; + callbacks.onPoolReady(encoder_for_retry_request, + cm_.thread_local_cluster_.conn_pool_.host_, upstream_stream_info_, + Http::Protocol::Http10); + return nullptr; + })); + router_.retry_state_->callback_(); + + // Simulate the load balancer to call the `overrideHostToSelect` again. The upstream override host + // will be ignored when the request is retried. + EXPECT_CALL(callbacks_, upstreamOverrideHost()).Times(0); + EXPECT_EQ(absl::nullopt, router_.overrideHostToSelect()); + + // Normal response. + Http::ResponseHeaderMapPtr response_headers_200( + new Http::TestResponseHeaderMapImpl{{":status", "200"}}); + + EXPECT_CALL(*router_.retry_state_, shouldRetryHeaders(_, _)).WillOnce(Return(RetryStatus::No)); + ASSERT(response_decoder != nullptr); + // NOLINTNEXTLINE: Silence null pointer access warning + response_decoder->decodeHeaders(std::move(response_headers_200), true); + + EXPECT_EQ(2, callbacks_.stream_info_.attemptCount().value()); + + router_.onDestroy(); +} + } // namespace Router } // namespace Envoy diff --git a/test/extensions/filters/http/stateful_session/BUILD b/test/extensions/filters/http/stateful_session/BUILD new file mode 100644 index 0000000000000..5d08332e635de --- /dev/null +++ b/test/extensions/filters/http/stateful_session/BUILD @@ -0,0 +1,59 @@ +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 = "stateful_session_test", + srcs = [ + "stateful_session_test.cc", + ], + extension_names = ["envoy.filters.http.stateful_session"], + deps = [ + "//source/extensions/filters/http/stateful_session:config", + "//test/mocks/api:api_mocks", + "//test/mocks/http:http_mocks", + "//test/mocks/http:stateful_session_mock", + "//test/mocks/server:factory_context_mocks", + "//test/test_common:registry_lib", + "//test/test_common:utility_lib", + ], +) + +envoy_extension_cc_test( + name = "stateful_session_integration_test", + srcs = [ + "stateful_session_integration_test.cc", + ], + extension_names = ["envoy.filters.http.stateful_session"], + deps = [ + "//source/common/protobuf", + "//source/extensions/filters/http/stateful_session:config", + "//source/extensions/http/stateful_session/cookie:config", + "//test/integration:http_integration_lib", + "//test/test_common:utility_lib", + "@envoy_api//envoy/config/endpoint/v3:pkg_cc_proto", + ], +) + +envoy_extension_cc_test( + name = "config_test", + srcs = ["config_test.cc"], + extension_names = ["envoy.filters.http.stateful_session"], + deps = [ + "//source/extensions/filters/http/stateful_session:config", + "//test/mocks/http:stateful_session_mock", + "//test/mocks/server:factory_context_mocks", + "//test/mocks/server:instance_mocks", + "//test/test_common:registry_lib", + "//test/test_common:utility_lib", + ], +) diff --git a/test/extensions/filters/http/stateful_session/config_test.cc b/test/extensions/filters/http/stateful_session/config_test.cc new file mode 100644 index 0000000000000..e55821cf7ca71 --- /dev/null +++ b/test/extensions/filters/http/stateful_session/config_test.cc @@ -0,0 +1,81 @@ +#include "source/extensions/filters/http/stateful_session/config.h" + +#include "test/mocks/http/stateful_session.h" +#include "test/mocks/server/factory_context.h" +#include "test/mocks/server/instance.h" +#include "test/test_common/registry.h" +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +using testing::NiceMock; + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace StatefulSession { +namespace { + +constexpr absl::string_view ConfigYaml = R"EOF( +session_state: + name: "envoy.http.stateful_session.mock" + typed_config: {} +)EOF"; + +constexpr absl::string_view DisableYaml = R"EOF( +disabled: true +)EOF"; + +constexpr absl::string_view RouteConfigYaml = R"EOF( +stateful_session: + session_state: + name: "envoy.http.stateful_session.mock" + typed_config: {} +)EOF"; + +constexpr absl::string_view NotExistYaml = R"EOF( +stateful_session: + session_state: + name: "envoy.http.stateful_session.not_exist" + typed_config: {} +)EOF"; + +TEST(StatefulSessionFactoryConfigTest, SimpleConfigTest) { + testing::NiceMock config_factory; + Registry::InjectFactory registration(config_factory); + + ProtoConfig proto_config; + PerRouteProtoConfig proto_route_config; + PerRouteProtoConfig disabled_config; + PerRouteProtoConfig not_exist_config; + + TestUtility::loadFromYamlAndValidate(std::string(ConfigYaml), proto_config); + TestUtility::loadFromYamlAndValidate(std::string(RouteConfigYaml), proto_route_config); + TestUtility::loadFromYamlAndValidate(std::string(DisableYaml), disabled_config); + TestUtility::loadFromYamlAndValidate(std::string(NotExistYaml), not_exist_config); + + testing::NiceMock context; + testing::NiceMock server_context; + StatefulSessionFactoryConfig factory; + + Http::FilterFactoryCb cb = factory.createFilterFactoryFromProto(proto_config, "stats", context); + Http::MockFilterChainFactoryCallbacks filter_callbacks; + EXPECT_CALL(filter_callbacks, addStreamFilter(_)); + cb(filter_callbacks); + + EXPECT_NO_THROW(factory.createRouteSpecificFilterConfig(proto_route_config, server_context, + context.messageValidationVisitor())); + EXPECT_NO_THROW(factory.createRouteSpecificFilterConfig(disabled_config, server_context, + context.messageValidationVisitor())); + EXPECT_THROW_WITH_MESSAGE( + factory.createRouteSpecificFilterConfig(not_exist_config, server_context, + context.messageValidationVisitor()), + EnvoyException, + "Didn't find a registered implementation for name: 'envoy.http.stateful_session.not_exist'"); +} + +} // namespace +} // namespace StatefulSession +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/stateful_session/stateful_session_integration_test.cc b/test/extensions/filters/http/stateful_session/stateful_session_integration_test.cc new file mode 100644 index 0000000000000..2d8b102b50041 --- /dev/null +++ b/test/extensions/filters/http/stateful_session/stateful_session_integration_test.cc @@ -0,0 +1,393 @@ +#include +#include + +#include "envoy/config/endpoint/v3/endpoint_components.pb.h" + +#include "source/common/common/base64.h" +#include "source/common/http/utility.h" +#include "source/common/protobuf/protobuf.h" +#include "source/extensions/filters/http/stateful_session/stateful_session.h" + +#include "test/integration/http_integration.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace StatefulSession { +namespace { + +class StatefulSessionIntegrationTest : public Envoy::HttpIntegrationTest, public testing::Test { +public: + StatefulSessionIntegrationTest() + : HttpIntegrationTest( + Http::CodecType::HTTP1, + [](int i) { return Network::Utility::parseInternetAddress("127.0.0.1", 50000 + i); }, + Network::Address::IpVersion::v4) { + // Create 4 different upstream server for stateful session test. + setUpstreamCount(4); + + skipPortUsageValidation(); + + // Update endpoints of default cluster `cluster_0` to 4 different fake upstreams. + config_helper_.addConfigModifier([](envoy::config::bootstrap::v3::Bootstrap& bootstrap) { + auto* cluster_0 = bootstrap.mutable_static_resources()->mutable_clusters()->Mutable(0); + ASSERT(cluster_0->name() == "cluster_0"); + auto* endpoint = cluster_0->mutable_load_assignment()->mutable_endpoints()->Mutable(0); + + const std::string EndpointsYaml = R"EOF( + lb_endpoints: + - endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: 50000 + - endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: 50001 + - endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: 50002 + - endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: 50003 + )EOF"; + + envoy::config::endpoint::v3::LocalityLbEndpoints new_lb_endpints; + TestUtility::loadFromYaml(EndpointsYaml, new_lb_endpints); + *endpoint = new_lb_endpints; + }); + } + + // Initialize route filter and per route config. + void initializeFilterAndRoute(const std::string& filter_yaml, + const std::string& per_route_config_yaml) { + config_helper_.prependFilter(filter_yaml); + + // Create virtual host with domain `stateful.session.com` and default route to `cluster_0` + auto virtual_host = config_helper_.createVirtualHost("stateful.session.com"); + + // Update per route config of default route. + if (!per_route_config_yaml.empty()) { + auto* route = virtual_host.mutable_routes(0); + ProtobufWkt::Any per_route_config; + TestUtility::loadFromYaml(per_route_config_yaml, per_route_config); + + route->mutable_typed_per_filter_config()->insert( + {"envoy.filters.http.stateful_session", per_route_config}); + } + config_helper_.addVirtualHost(virtual_host); + + initialize(); + } +}; + +static const std::string STATEFUL_SESSION_FILTER = + R"EOF( +name: envoy.filters.http.stateful_session +typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.stateful_session.v3.StatefulSession + session_state: + name: envoy.http.stateful_session.cookie + typed_config: + "@type": type.googleapis.com/envoy.extensions.http.stateful_session.cookie.v3.CookieBasedSessionState + cookie: + name: global-session-cookie + path: /path + ttl: 120s +)EOF"; + +static const std::string DISABLE_STATEFUL_SESSION = + R"EOF( +"@type": type.googleapis.com/envoy.extensions.filters.http.stateful_session.v3.StatefulSessionPerRoute +disabled: true +)EOF"; + +static const std::string OVERRIDE_STATEFUL_SESSION = + R"EOF( +"@type": type.googleapis.com/envoy.extensions.filters.http.stateful_session.v3.StatefulSessionPerRoute +stateful_session: + session_state: + name: envoy.http.stateful_session.cookie + typed_config: + "@type": type.googleapis.com/envoy.extensions.http.stateful_session.cookie.v3.CookieBasedSessionState + cookie: + name: route-session-cookie + path: /path + ttl: 120s +)EOF"; + +TEST_F(StatefulSessionIntegrationTest, NormalStatefulSession) { + initializeFilterAndRoute(STATEFUL_SESSION_FILTER, ""); + + codec_client_ = makeHttpConnection(lookupPort("http")); + + Http::TestRequestHeaderMapImpl request_headers{{":method", "GET"}, + {":path", "/test"}, + {":scheme", "http"}, + {":authority", "stateful.session.com"}}; + + auto response = codec_client_->makeRequestWithBody(request_headers, 0); + + auto upstream_index = waitForNextUpstreamRequest({0, 1, 2, 3}); + ASSERT(upstream_index.has_value()); + const std::string address_string = fmt::format("127.0.0.1:{}", upstream_index.value() + 50000); + const std::string encoded_address = Envoy::Base64::encode(address_string.data(), 15); + + upstream_request_->encodeHeaders(default_response_headers_, true); + + ASSERT_TRUE(response->waitForEndStream()); + + EXPECT_TRUE(upstream_request_->complete()); + EXPECT_TRUE(response->complete()); + + // The selected upstream server address would be selected to the response headers. + EXPECT_EQ( + Envoy::Http::Utility::makeSetCookieValue("global-session-cookie", encoded_address, "/path", + std::chrono::seconds(120), true), + response->headers().get(Http::LowerCaseString("set-cookie"))[0]->value().getStringView()); + + cleanupUpstreamAndDownstream(); +} + +TEST_F(StatefulSessionIntegrationTest, DownstreamRequestWithStatefulSessionCookie) { + initializeFilterAndRoute(STATEFUL_SESSION_FILTER, ""); + + { + codec_client_ = makeHttpConnection(lookupPort("http")); + Http::TestRequestHeaderMapImpl request_headers{ + {":method", "GET"}, + {":path", "/test"}, + {":scheme", "http"}, + {":authority", "stateful.session.com"}, + {"cookie", fmt::format("global-session-cookie=\"{}\"", + Envoy::Base64::encode("127.0.0.1:50001", 15))}}; + + auto response = codec_client_->makeRequestWithBody(request_headers, 0); + + // `127.0.0.1:50001` should be selected and it's upstream index is 1. + auto upstream_index = waitForNextUpstreamRequest({0, 1, 2, 3}); + EXPECT_EQ(upstream_index.value(), 1); + + upstream_request_->encodeHeaders(default_response_headers_, true); + + ASSERT_TRUE(response->waitForEndStream()); + + EXPECT_TRUE(upstream_request_->complete()); + EXPECT_TRUE(response->complete()); + + // No response header to be added. + EXPECT_TRUE(response->headers().get(Http::LowerCaseString("set-cookie")).empty()); + + cleanupUpstreamAndDownstream(); + } + + { + codec_client_ = makeHttpConnection(lookupPort("http")); + Http::TestRequestHeaderMapImpl request_headers{ + {":method", "GET"}, + {":path", "/test"}, + {":scheme", "http"}, + {":authority", "stateful.session.com"}, + {"cookie", fmt::format("global-session-cookie=\"{}\"", + Envoy::Base64::encode("127.0.0.1:50002", 15))}}; + + auto response = codec_client_->makeRequestWithBody(request_headers, 0); + + // `127.0.0.1:50002` should be selected and it's upstream index is 2. + auto upstream_index = waitForNextUpstreamRequest({0, 1, 2, 3}); + EXPECT_EQ(upstream_index.value(), 2); + + upstream_request_->encodeHeaders(default_response_headers_, true); + + ASSERT_TRUE(response->waitForEndStream()); + + EXPECT_TRUE(upstream_request_->complete()); + EXPECT_TRUE(response->complete()); + + // No response header to be added. + EXPECT_TRUE(response->headers().get(Http::LowerCaseString("set-cookie")).empty()); + + cleanupUpstreamAndDownstream(); + } + + // Test the case that stateful session cookie with unknown server address. + { + codec_client_ = makeHttpConnection(lookupPort("http")); + Http::TestRequestHeaderMapImpl request_headers{ + {":method", "GET"}, + {":path", "/test"}, + {":scheme", "http"}, + {":authority", "stateful.session.com"}, + {"cookie", fmt::format("global-session-cookie=\"{}\"", + Envoy::Base64::encode("127.0.0.1:50005", 15))}}; + + auto response = codec_client_->makeRequestWithBody(request_headers, 0); + + auto upstream_index = waitForNextUpstreamRequest({0, 1, 2, 3}); + ASSERT(upstream_index.has_value()); + const std::string address_string = fmt::format("127.0.0.1:{}", upstream_index.value() + 50000); + const std::string encoded_address = Envoy::Base64::encode(address_string.data(), 15); + + upstream_request_->encodeHeaders(default_response_headers_, true); + + ASSERT_TRUE(response->waitForEndStream()); + + EXPECT_TRUE(upstream_request_->complete()); + EXPECT_TRUE(response->complete()); + + // The selected upstream server address would be selected to the response headers. + EXPECT_EQ( + Envoy::Http::Utility::makeSetCookieValue("global-session-cookie", encoded_address, "/path", + std::chrono::seconds(120), true), + response->headers().get(Http::LowerCaseString("set-cookie"))[0]->value().getStringView()); + + cleanupUpstreamAndDownstream(); + } +} + +TEST_F(StatefulSessionIntegrationTest, StatefulSessionDisabledByRoute) { + initializeFilterAndRoute(STATEFUL_SESSION_FILTER, DISABLE_STATEFUL_SESSION); + + uint64_t first_index = 0; + uint64_t second_index = 0; + + { + codec_client_ = makeHttpConnection(lookupPort("http")); + Http::TestRequestHeaderMapImpl request_headers{ + {":method", "GET"}, + {":path", "/test"}, + {":scheme", "http"}, + {":authority", "stateful.session.com"}, + {"cookie", fmt::format("global-session-cookie=\"{}\"", + Envoy::Base64::encode("127.0.0.1:50001", 15))}}; + + auto response = codec_client_->makeRequestWithBody(request_headers, 0); + + auto upstream_index = waitForNextUpstreamRequest({0, 1, 2, 3}); + ASSERT(upstream_index.has_value()); + first_index = upstream_index.value(); + + upstream_request_->encodeHeaders(default_response_headers_, true); + + ASSERT_TRUE(response->waitForEndStream()); + + EXPECT_TRUE(upstream_request_->complete()); + EXPECT_TRUE(response->complete()); + + // No response header to be added. + EXPECT_TRUE(response->headers().get(Http::LowerCaseString("set-cookie")).empty()); + + cleanupUpstreamAndDownstream(); + } + + { + codec_client_ = makeHttpConnection(lookupPort("http")); + Http::TestRequestHeaderMapImpl request_headers{ + {":method", "GET"}, + {":path", "/test"}, + {":scheme", "http"}, + {":authority", "stateful.session.com"}, + {"cookie", fmt::format("global-session-cookie=\"{}\"", + Envoy::Base64::encode("127.0.0.1:50001", 15))}}; + + auto response = codec_client_->makeRequestWithBody(request_headers, 0); + + auto upstream_index = waitForNextUpstreamRequest({0, 1, 2, 3}); + ASSERT(upstream_index.has_value()); + second_index = upstream_index.value(); + + upstream_request_->encodeHeaders(default_response_headers_, true); + + ASSERT_TRUE(response->waitForEndStream()); + + EXPECT_TRUE(upstream_request_->complete()); + EXPECT_TRUE(response->complete()); + + // No response header to be added. + EXPECT_TRUE(response->headers().get(Http::LowerCaseString("set-cookie")).empty()); + + cleanupUpstreamAndDownstream(); + } + + // Choose different upstream servers by default. + EXPECT_NE(first_index, second_index); +} + +TEST_F(StatefulSessionIntegrationTest, StatefulSessionOverriddenByRoute) { + initializeFilterAndRoute(STATEFUL_SESSION_FILTER, OVERRIDE_STATEFUL_SESSION); + + { + codec_client_ = makeHttpConnection(lookupPort("http")); + Http::TestRequestHeaderMapImpl request_headers{ + {":method", "GET"}, + {":path", "/test"}, + {":scheme", "http"}, + {":authority", "stateful.session.com"}, + {"cookie", fmt::format("global-session-cookie=\"{}\"", + Envoy::Base64::encode("127.0.0.1:50001", 15))}}; + + auto response = codec_client_->makeRequestWithBody(request_headers, 0); + + auto upstream_index = waitForNextUpstreamRequest({0, 1, 2, 3}); + ASSERT(upstream_index.has_value()); + const std::string address_string = fmt::format("127.0.0.1:{}", upstream_index.value() + 50000); + const std::string encoded_address = Envoy::Base64::encode(address_string.data(), 15); + + upstream_request_->encodeHeaders(default_response_headers_, true); + + ASSERT_TRUE(response->waitForEndStream()); + + EXPECT_TRUE(upstream_request_->complete()); + EXPECT_TRUE(response->complete()); + + EXPECT_EQ( + Envoy::Http::Utility::makeSetCookieValue("route-session-cookie", encoded_address, "/path", + std::chrono::seconds(120), true), + response->headers().get(Http::LowerCaseString("set-cookie"))[0]->value().getStringView()); + + cleanupUpstreamAndDownstream(); + } + + { + codec_client_ = makeHttpConnection(lookupPort("http")); + Http::TestRequestHeaderMapImpl request_headers{ + {":method", "GET"}, + {":path", "/test"}, + {":scheme", "http"}, + {":authority", "stateful.session.com"}, + {"cookie", + fmt::format("route-session-cookie=\"{}\"", Envoy::Base64::encode("127.0.0.1:50002", 15))}}; + + auto response = codec_client_->makeRequestWithBody(request_headers, 0); + + // Stateful session is overridden and `127.0.0.1:50002` should be selected. + auto upstream_index = waitForNextUpstreamRequest({0, 1, 2, 3}); + EXPECT_EQ(upstream_index.value(), 2); + + upstream_request_->encodeHeaders(default_response_headers_, true); + + ASSERT_TRUE(response->waitForEndStream()); + + EXPECT_TRUE(upstream_request_->complete()); + EXPECT_TRUE(response->complete()); + + // No response header to be added. + EXPECT_TRUE(response->headers().get(Http::LowerCaseString("set-cookie")).empty()); + + cleanupUpstreamAndDownstream(); + } +} + +} // namespace +} // namespace StatefulSession +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/filters/http/stateful_session/stateful_session_test.cc b/test/extensions/filters/http/stateful_session/stateful_session_test.cc new file mode 100644 index 0000000000000..47bf867c4d405 --- /dev/null +++ b/test/extensions/filters/http/stateful_session/stateful_session_test.cc @@ -0,0 +1,198 @@ +#include + +#include "source/extensions/filters/http/stateful_session/stateful_session.h" + +#include "test/mocks/http/mocks.h" +#include "test/mocks/http/stateful_session.h" +#include "test/mocks/server/factory_context.h" +#include "test/test_common/registry.h" +#include "test/test_common/utility.h" + +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +using testing::NiceMock; +using testing::Return; + +namespace Envoy { +namespace Extensions { +namespace HttpFilters { +namespace StatefulSession { +namespace { + +class StatefulSessionTest : public testing::Test { +public: + void initialize(absl::string_view config, absl::string_view route_config = "") { + Http::MockSessionStateFactoryConfig config_factory; + Registry::InjectFactory registration(config_factory); + + factory_ = std::make_shared>(); + EXPECT_CALL(config_factory, createSessionStateFactory(_, _)).WillOnce(Return(factory_)); + + ASSERT(!config.empty()); + ProtoConfig proto_config; + TestUtility::loadFromYaml(std::string(config), proto_config); + + config_ = std::make_shared(proto_config, context_); + + filter_ = std::make_shared(config_.get()); + filter_->setDecoderFilterCallbacks(decoder_callbacks_); + filter_->setEncoderFilterCallbacks(encoder_callbacks_); + + if (!route_config.empty()) { + PerRouteProtoConfig proto_route_config; + TestUtility::loadFromYaml(std::string(route_config), proto_route_config); + + if (proto_route_config.has_stateful_session()) { + route_factory_ = std::make_shared>(); + EXPECT_CALL(config_factory, createSessionStateFactory(_, _)) + .WillOnce(Return(route_factory_)); + } + + route_config_ = std::make_shared(proto_route_config, context_); + + ON_CALL(*decoder_callbacks_.route_, mostSpecificPerFilterConfig(_)) + .WillByDefault(Return(route_config_.get())); + } + }; + + NiceMock context_; + + NiceMock decoder_callbacks_; + NiceMock encoder_callbacks_; + + std::shared_ptr> factory_; + std::shared_ptr> route_factory_; + + StatefulSessionConfigSharedPtr config_; + PerRouteStatefulSessionConfigSharedPtr route_config_; + + std::shared_ptr filter_; +}; + +constexpr absl::string_view ConfigYaml = R"EOF( +session_state: + name: "envoy.http.stateful_session.mock" + typed_config: {} +)EOF"; + +constexpr absl::string_view DisableYaml = R"EOF( +disabled: true +)EOF"; + +constexpr absl::string_view RouteConfigYaml = R"EOF( +stateful_session: + session_state: + name: "envoy.http.stateful_session.mock" + typed_config: {} +)EOF"; + +// Test the normal case that the stateful session is enabled. +TEST_F(StatefulSessionTest, NormalSessionStateTest) { + initialize(ConfigYaml); + Http::TestRequestHeaderMapImpl request_headers{ + {":path", "/"}, {":method", "GET"}, {":authority", "test.com"}}; + Http::TestResponseHeaderMapImpl response_headers{{":status", "200"}}; + + auto session_state = std::make_unique>(); + auto raw_session_state = session_state.get(); + + EXPECT_CALL(*factory_, create(_)).WillOnce(Return(testing::ByMove(std::move(session_state)))); + EXPECT_CALL(*raw_session_state, upstreamAddress()) + .WillOnce(Return(absl::make_optional("1.2.3.4"))); + EXPECT_CALL(decoder_callbacks_, setUpstreamOverrideHost(_)) + .WillOnce(testing::Invoke([&](absl::string_view host) { EXPECT_EQ("1.2.3.4", host); })); + + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); + + EXPECT_CALL(*raw_session_state, onUpdate(_, _)); + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->encodeHeaders(response_headers, true)); +} + +// Test the case that the stateful session is disabled by the route config. +TEST_F(StatefulSessionTest, SessionStateDisabledByRoute) { + initialize(ConfigYaml, DisableYaml); + Http::TestRequestHeaderMapImpl request_headers{ + {":path", "/"}, {":method", "GET"}, {":authority", "test.com"}}; + Http::TestResponseHeaderMapImpl response_headers{{":status", "200"}}; + + EXPECT_CALL(*factory_, create(_)).Times(0); + + EXPECT_EQ(nullptr, filter_->sessionStateForTest().get()); + + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->encodeHeaders(response_headers, true)); +} + +// Test the case that the stateful session is override by the route config. +TEST_F(StatefulSessionTest, SessionStateOverrideByRoute) { + initialize(ConfigYaml, RouteConfigYaml); + Http::TestRequestHeaderMapImpl request_headers{ + {":path", "/"}, {":method", "GET"}, {":authority", "test.com"}}; + Http::TestResponseHeaderMapImpl response_headers{{":status", "200"}}; + + auto session_state = std::make_unique>(); + auto raw_session_state = session_state.get(); + + EXPECT_CALL(*route_factory_, create(_)) + .WillOnce(Return(testing::ByMove(std::move(session_state)))); + EXPECT_CALL(*raw_session_state, upstreamAddress()) + .WillOnce(Return(absl::make_optional("1.2.3.4"))); + EXPECT_CALL(decoder_callbacks_, setUpstreamOverrideHost(_)) + .WillOnce(testing::Invoke([&](absl::string_view host) { EXPECT_EQ("1.2.3.4", host); })); + + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); + + EXPECT_CALL(*raw_session_state, onUpdate(_, _)); + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->encodeHeaders(response_headers, true)); +} + +// Test the case that the session state has not valid upstream address. +TEST_F(StatefulSessionTest, SessionStateHasNoUpstreamAddress) { + initialize(ConfigYaml, RouteConfigYaml); + Http::TestRequestHeaderMapImpl request_headers{ + {":path", "/"}, {":method", "GET"}, {":authority", "test.com"}}; + Http::TestResponseHeaderMapImpl response_headers{{":status", "200"}}; + + auto session_state = std::make_unique>(); + auto raw_session_state = session_state.get(); + + EXPECT_CALL(*route_factory_, create(_)) + .WillOnce(Return(testing::ByMove(std::move(session_state)))); + EXPECT_CALL(*raw_session_state, upstreamAddress()).WillOnce(Return(absl::nullopt)); + + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); + + EXPECT_CALL(*raw_session_state, onUpdate(_, _)); + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->encodeHeaders(response_headers, true)); +} + +// Test the case that no valid upstream host. +TEST_F(StatefulSessionTest, NoUpstreamHost) { + initialize(ConfigYaml); + Http::TestRequestHeaderMapImpl request_headers{ + {":path", "/"}, {":method", "GET"}, {":authority", "test.com"}}; + Http::TestResponseHeaderMapImpl response_headers{{":status", "200"}}; + + auto session_state = std::make_unique>(); + auto raw_session_state = session_state.get(); + + EXPECT_CALL(*factory_, create(_)).WillOnce(Return(testing::ByMove(std::move(session_state)))); + EXPECT_CALL(*raw_session_state, upstreamAddress()) + .WillOnce(Return(absl::make_optional("1.2.3.4"))); + EXPECT_CALL(decoder_callbacks_, setUpstreamOverrideHost(_)) + .WillOnce(testing::Invoke([&](absl::string_view host) { EXPECT_EQ("1.2.3.4", host); })); + + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->decodeHeaders(request_headers, true)); + + encoder_callbacks_.stream_info_.setUpstreamInfo(nullptr); + EXPECT_CALL(*raw_session_state, onUpdate(_, _)).Times(0); + + EXPECT_EQ(Http::FilterHeadersStatus::Continue, filter_->encodeHeaders(response_headers, true)); +} + +} // namespace +} // namespace StatefulSession +} // namespace HttpFilters +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/http/stateful_session/cookie/BUILD b/test/extensions/http/stateful_session/cookie/BUILD new file mode 100644 index 0000000000000..491d34207e303 --- /dev/null +++ b/test/extensions/http/stateful_session/cookie/BUILD @@ -0,0 +1,36 @@ +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 = "cookie_test", + srcs = ["cookie_test.cc"], + extension_names = ["envoy.http.stateful_session.cookie"], + deps = [ + "//source/common/http:utility_lib", + "//source/extensions/http/stateful_session/cookie:cookie_lib", + "//test/mocks/upstream:host_mocks", + "//test/test_common:utility_lib", + ], +) + +envoy_extension_cc_test( + name = "config_test", + srcs = ["config_test.cc"], + extension_names = ["envoy.http.stateful_session.cookie"], + deps = [ + "//envoy/registry", + "//source/extensions/http/stateful_session/cookie:config", + "//test/mocks/server:factory_context_mocks", + "//test/test_common:utility_lib", + ], +) diff --git a/test/extensions/http/stateful_session/cookie/config_test.cc b/test/extensions/http/stateful_session/cookie/config_test.cc new file mode 100644 index 0000000000000..889bc4633160d --- /dev/null +++ b/test/extensions/http/stateful_session/cookie/config_test.cc @@ -0,0 +1,56 @@ +#include "source/extensions/http/stateful_session/cookie/config.h" + +#include "test/mocks/server/factory_context.h" +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace Http { +namespace StatefulSession { +namespace Cookie { +namespace { + +TEST(CookieBasedSessionStateFactoryConfigTest, Basic) { + auto* factory = Registry::FactoryRegistry::getFactory( + "envoy.http.stateful_session.cookie"); + ASSERT_NE(factory, nullptr); + + CookieBasedSessionStateProto proto_config; + const std::string yaml = R"EOF( + cookie: + name: override_host + path: /path + ttl: 5s + )EOF"; + TestUtility::loadFromYaml(yaml, proto_config); + + NiceMock context; + EXPECT_NE(factory->createSessionStateFactory(proto_config, context), nullptr); +} + +TEST(CookieBasedSessionStateFactoryConfigTest, NegativeTTL) { + auto* factory = Registry::FactoryRegistry::getFactory( + "envoy.http.stateful_session.cookie"); + ASSERT_NE(factory, nullptr); + + CookieBasedSessionStateProto proto_config; + const std::string yaml = R"EOF( + cookie: + name: override_host + path: /path + ttl: -1s + )EOF"; + TestUtility::loadFromYaml(yaml, proto_config); + + NiceMock context; + EXPECT_THROW(factory->createSessionStateFactory(proto_config, context), EnvoyException); +} + +} // namespace +} // namespace Cookie +} // namespace StatefulSession +} // namespace Http +} // namespace Extensions +} // namespace Envoy diff --git a/test/extensions/http/stateful_session/cookie/cookie_test.cc b/test/extensions/http/stateful_session/cookie/cookie_test.cc new file mode 100644 index 0000000000000..c4963b239e8f5 --- /dev/null +++ b/test/extensions/http/stateful_session/cookie/cookie_test.cc @@ -0,0 +1,93 @@ +#include "source/common/network/address_impl.h" +#include "source/extensions/http/stateful_session/cookie/cookie.h" + +#include "test/mocks/upstream/host.h" +#include "test/test_common/utility.h" + +#include "gtest/gtest.h" + +namespace Envoy { +namespace Extensions { +namespace Http { +namespace StatefulSession { +namespace Cookie { +namespace { + +TEST(CookieBasedSessionStateFactoryTest, EmptyCookieName) { + CookieBasedSessionStateProto config; + + EXPECT_THROW_WITH_MESSAGE(std::make_shared(config), + EnvoyException, + "Cookie key cannot be empty for cookie based stateful sessions"); + config.mutable_cookie()->set_name("override_host"); + + EXPECT_NO_THROW(std::make_shared(config)); +} + +TEST(CookieBasedSessionStateFactoryTest, SessionStateTest) { + testing::NiceMock mock_host; + + { + CookieBasedSessionStateProto config; + config.mutable_cookie()->set_name("override_host"); + CookieBasedSessionStateFactory factory(config); + + // No valid address in the request headers. + Envoy::Http::TestRequestHeaderMapImpl request_headers; + auto session_state = factory.create(request_headers); + EXPECT_EQ(absl::nullopt, session_state->upstreamAddress()); + + auto upstream_host = std::make_shared("1.2.3.4", 80); + EXPECT_CALL(mock_host, address()).WillOnce(testing::Return(upstream_host)); + + Envoy::Http::TestResponseHeaderMapImpl response_headers; + session_state->onUpdate(mock_host, response_headers); + + // No valid address then update it by set-cookie. + EXPECT_EQ(response_headers.get_("set-cookie"), + Envoy::Http::Utility::makeSetCookieValue("override_host", + Envoy::Base64::encode("1.2.3.4:80", 10), "", + std::chrono::seconds(0), true)); + } + + { + CookieBasedSessionStateProto config; + config.mutable_cookie()->set_name("override_host"); + config.mutable_cookie()->set_path("/path"); + config.mutable_cookie()->mutable_ttl()->set_seconds(5); + CookieBasedSessionStateFactory factory(config); + + // Get upstream address from request headers. + Envoy::Http::TestRequestHeaderMapImpl request_headers = { + {"cookie", "override_host=" + Envoy::Base64::encode("1.2.3.4:80", 10)}}; + auto session_state = factory.create(request_headers); + EXPECT_EQ("1.2.3.4:80", session_state->upstreamAddress().value()); + + auto upstream_host = std::make_shared("1.2.3.4", 80); + EXPECT_CALL(mock_host, address()).WillOnce(testing::Return(upstream_host)); + + Envoy::Http::TestResponseHeaderMapImpl response_headers; + session_state->onUpdate(mock_host, response_headers); + + // Session state is not updated and then do nothing. + EXPECT_EQ(response_headers.get_("set-cookie"), ""); + + auto upstream_host_2 = std::make_shared("2.3.4.5", 80); + EXPECT_CALL(mock_host, address()).WillOnce(testing::Return(upstream_host_2)); + + session_state->onUpdate(mock_host, response_headers); + + // Update session state because the current request is routed to a new upstream host. + EXPECT_EQ(response_headers.get_("set-cookie"), + Envoy::Http::Utility::makeSetCookieValue("override_host", + Envoy::Base64::encode("2.3.4.5:80", 10), + "/path", std::chrono::seconds(5), true)); + } +} + +} // namespace +} // namespace Cookie +} // namespace StatefulSession +} // namespace Http +} // namespace Extensions +} // namespace Envoy diff --git a/test/mocks/http/BUILD b/test/mocks/http/BUILD index 063aa41e96586..7a583cc71350c 100644 --- a/test/mocks/http/BUILD +++ b/test/mocks/http/BUILD @@ -79,6 +79,15 @@ envoy_cc_mock( ], ) +envoy_cc_mock( + name = "stateful_session_mock", + srcs = ["stateful_session.cc"], + hdrs = ["stateful_session.h"], + deps = [ + "//envoy/http:stateful_session_interface", + ], +) + envoy_cc_mock( name = "stream_decoder_mock", srcs = ["stream_decoder.cc"], diff --git a/test/mocks/http/mocks.cc b/test/mocks/http/mocks.cc index 959e1bb07890d..9556ccc2bddf7 100644 --- a/test/mocks/http/mocks.cc +++ b/test/mocks/http/mocks.cc @@ -85,6 +85,7 @@ MockStreamDecoderFilterCallbacks::MockStreamDecoderFilterCallbacks() { })); ON_CALL(*this, routeConfig()) .WillByDefault(Return(absl::optional())); + ON_CALL(*this, upstreamOverrideHost()).WillByDefault(Return(absl::optional())); } MockStreamDecoderFilterCallbacks::~MockStreamDecoderFilterCallbacks() = default; diff --git a/test/mocks/http/mocks.h b/test/mocks/http/mocks.h index 58b20487a421e..73e0a328c3505 100644 --- a/test/mocks/http/mocks.h +++ b/test/mocks/http/mocks.h @@ -270,6 +270,8 @@ class MockStreamDecoderFilterCallbacks : public StreamDecoderFilterCallbacks, const absl::optional grpc_status, absl::string_view details)); MOCK_METHOD(Buffer::BufferMemoryAccountSharedPtr, account, (), (const)); + MOCK_METHOD(void, setUpstreamOverrideHost, (absl::string_view host)); + MOCK_METHOD(absl::optional, upstreamOverrideHost, (), (const)); Buffer::InstancePtr buffer_; std::list callbacks_{}; diff --git a/test/mocks/http/stateful_session.cc b/test/mocks/http/stateful_session.cc new file mode 100644 index 0000000000000..4a70626e7a7a2 --- /dev/null +++ b/test/mocks/http/stateful_session.cc @@ -0,0 +1,20 @@ +#include "test/mocks/http/stateful_session.h" + +using testing::_; + +namespace Envoy { +namespace Http { + +MockSessionStateFactory::MockSessionStateFactory() { + ON_CALL(*this, create(_)) + .WillByDefault( + Return(testing::ByMove(std::make_unique>()))); +} + +MockSessionStateFactoryConfig::MockSessionStateFactoryConfig() { + ON_CALL(*this, createSessionStateFactory(_, _)) + .WillByDefault(Return(std::make_shared>())); +} + +} // namespace Http +} // namespace Envoy diff --git a/test/mocks/http/stateful_session.h b/test/mocks/http/stateful_session.h new file mode 100644 index 0000000000000..c89ea4156e85c --- /dev/null +++ b/test/mocks/http/stateful_session.h @@ -0,0 +1,40 @@ +#pragma once + +#include "envoy/http/stateful_session.h" + +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +namespace Envoy { +namespace Http { + +class MockSessionState : public SessionState { +public: + MOCK_METHOD(absl::optional, upstreamAddress, (), (const)); + MOCK_METHOD(void, onUpdate, + (const Upstream::HostDescription& host, Http::ResponseHeaderMap& headers)); +}; + +class MockSessionStateFactory : public Http::SessionStateFactory { +public: + MockSessionStateFactory(); + + MOCK_METHOD(Http::SessionStatePtr, create, (const Http::RequestHeaderMap& headers), (const)); +}; + +class MockSessionStateFactoryConfig : public Http::SessionStateFactoryConfig { +public: + MockSessionStateFactoryConfig(); + + ProtobufTypes::MessagePtr createEmptyConfigProto() override { + return std::make_unique(); + } + + MOCK_METHOD(SessionStateFactorySharedPtr, createSessionStateFactory, + (const Protobuf::Message&, Server::Configuration::CommonFactoryContext&)); + + std::string name() const override { return "envoy.http.stateful_session.mock"; } +}; + +} // namespace Http +} // namespace Envoy diff --git a/test/mocks/router/BUILD b/test/mocks/router/BUILD index 1cfbbf669bf75..e8e14aedbf528 100644 --- a/test/mocks/router/BUILD +++ b/test/mocks/router/BUILD @@ -14,6 +14,7 @@ envoy_cc_mock( hdrs = ["mocks.h"], deps = [ "//envoy/event:dispatcher_interface", + "//envoy/http:stateful_session_interface", "//envoy/json:json_object_interface", "//envoy/local_info:local_info_interface", "//envoy/router:route_config_provider_manager_interface", diff --git a/test/mocks/router/mocks.h b/test/mocks/router/mocks.h index 9942d0d936991..6af0430970419 100644 --- a/test/mocks/router/mocks.h +++ b/test/mocks/router/mocks.h @@ -16,6 +16,7 @@ #include "envoy/event/dispatcher.h" #include "envoy/extensions/filters/network/http_connection_manager/v3/http_connection_manager.pb.h" #include "envoy/http/hash_policy.h" +#include "envoy/http/stateful_session.h" #include "envoy/local_info/local_info.h" #include "envoy/router/rds.h" #include "envoy/router/route_config_provider_manager.h" diff --git a/tools/extensions/extensions_check.py b/tools/extensions/extensions_check.py index 313206572d122..ad539fd18f115 100644 --- a/tools/extensions/extensions_check.py +++ b/tools/extensions/extensions_check.py @@ -55,7 +55,8 @@ "envoy.stats_sinks", "envoy.thrift_proxy.filters", "envoy.tracers", "envoy.sip_proxy.filters", "envoy.transport_sockets.downstream", "envoy.transport_sockets.upstream", "envoy.tls.cert_validator", "envoy.upstreams", "envoy.wasm.runtime", "envoy.common.key_value", - "envoy.network.dns_resolver", "envoy.rbac.matchers", "envoy.access_loggers.extension_filters") + "envoy.network.dns_resolver", "envoy.rbac.matchers", "envoy.access_loggers.extension_filters", + "envoy.http.stateful_session") EXTENSION_STATUS_VALUES = ( # This extension is stable and is expected to be production usable.