diff --git a/api/v1alpha1/envoyproxy_types.go b/api/v1alpha1/envoyproxy_types.go index 910c6d1503a..e2ada31c3fc 100644 --- a/api/v1alpha1/envoyproxy_types.go +++ b/api/v1alpha1/envoyproxy_types.go @@ -112,6 +112,8 @@ type EnvoyProxySpec struct { // // - envoy.filters.http.jwt_authn // + // - envoy.filters.http.stateful_session + // // - envoy.filters.http.ext_proc // // - envoy.filters.http.wasm @@ -172,7 +174,7 @@ type FilterPosition struct { } // EnvoyFilter defines the type of Envoy HTTP filter. -// +kubebuilder:validation:Enum=envoy.filters.http.health_check;envoy.filters.http.fault;envoy.filters.http.cors;envoy.filters.http.ext_authz;envoy.filters.http.basic_auth;envoy.filters.http.oauth2;envoy.filters.http.jwt_authn;envoy.filters.http.ext_proc;envoy.filters.http.wasm;envoy.filters.http.rbac;envoy.filters.http.local_ratelimit;envoy.filters.http.ratelimit +// +kubebuilder:validation:Enum=envoy.filters.http.health_check;envoy.filters.http.fault;envoy.filters.http.cors;envoy.filters.http.ext_authz;envoy.filters.http.basic_auth;envoy.filters.http.oauth2;envoy.filters.http.jwt_authn;envoy.filters.http.stateful_session;envoy.filters.http.ext_proc;envoy.filters.http.wasm;envoy.filters.http.rbac;envoy.filters.http.local_ratelimit;envoy.filters.http.ratelimit type EnvoyFilter string const ( @@ -197,6 +199,9 @@ const ( // EnvoyFilterJWTAuthn defines the Envoy HTTP JWT authentication filter. EnvoyFilterJWTAuthn EnvoyFilter = "envoy.filters.http.jwt_authn" + // EnvoyFilterSessionPersistence defines the Envoy HTTP session persistence filter. + EnvoyFilterSessionPersistence EnvoyFilter = "envoy.filters.http.stateful_session" + // EnvoyFilterExtProc defines the Envoy HTTP external process filter. EnvoyFilterExtProc EnvoyFilter = "envoy.filters.http.ext_proc" diff --git a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_envoyproxies.yaml b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_envoyproxies.yaml index 0743318409c..874dc98d4a7 100644 --- a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_envoyproxies.yaml +++ b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_envoyproxies.yaml @@ -251,6 +251,9 @@ spec: - envoy.filters.http.jwt_authn + - envoy.filters.http.stateful_session + + - envoy.filters.http.ext_proc @@ -286,6 +289,7 @@ spec: - envoy.filters.http.basic_auth - envoy.filters.http.oauth2 - envoy.filters.http.jwt_authn + - envoy.filters.http.stateful_session - envoy.filters.http.ext_proc - envoy.filters.http.wasm - envoy.filters.http.rbac @@ -304,6 +308,7 @@ spec: - envoy.filters.http.basic_auth - envoy.filters.http.oauth2 - envoy.filters.http.jwt_authn + - envoy.filters.http.stateful_session - envoy.filters.http.ext_proc - envoy.filters.http.wasm - envoy.filters.http.rbac @@ -320,6 +325,7 @@ spec: - envoy.filters.http.basic_auth - envoy.filters.http.oauth2 - envoy.filters.http.jwt_authn + - envoy.filters.http.stateful_session - envoy.filters.http.ext_proc - envoy.filters.http.wasm - envoy.filters.http.rbac diff --git a/examples/extension-server/cmd/extension-server/main.go b/examples/extension-server/cmd/extension-server/main.go index 33e08ddc914..9df1f4a885c 100644 --- a/examples/extension-server/cmd/extension-server/main.go +++ b/examples/extension-server/cmd/extension-server/main.go @@ -13,11 +13,11 @@ import ( "os/signal" "syscall" - pb "github.com/envoyproxy/gateway/proto/extension" + "github.com/exampleorg/envoygateway-extension/internal/extensionserver" "github.com/urfave/cli/v2" "google.golang.org/grpc" - "github.com/exampleorg/envoygateway-extension/internal/extensionserver" + pb "github.com/envoyproxy/gateway/proto/extension" ) func main() { diff --git a/examples/extension-server/internal/extensionserver/server.go b/examples/extension-server/internal/extensionserver/server.go index a2776a9f966..2c060869b88 100644 --- a/examples/extension-server/internal/extensionserver/server.go +++ b/examples/extension-server/internal/extensionserver/server.go @@ -11,15 +11,15 @@ import ( "fmt" "log/slog" - pb "github.com/envoyproxy/gateway/proto/extension" corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" bav3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/basic_auth/v3" hcm "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" "github.com/envoyproxy/go-control-plane/pkg/wellknown" + "github.com/exampleorg/envoygateway-extension/api/v1alpha1" "google.golang.org/protobuf/types/known/anypb" - "github.com/exampleorg/envoygateway-extension/api/v1alpha1" + pb "github.com/envoyproxy/gateway/proto/extension" ) type Server struct { diff --git a/internal/gatewayapi/route.go b/internal/gatewayapi/route.go index 336e931cfce..8a3c0272276 100644 --- a/internal/gatewayapi/route.go +++ b/internal/gatewayapi/route.go @@ -314,12 +314,60 @@ func (t *Translator) processHTTPRouteRule(httpRoute *HTTPRouteContext, ruleIdx i ruleRoutes = append(ruleRoutes, irRoute) } + var sessionPersistence *ir.SessionPersistence + if rule.SessionPersistence != nil { + if rule.SessionPersistence.IdleTimeout != nil { + return nil, fmt.Errorf("idle timeout is not supported in envoy gateway") + } + + var sessionName string + if rule.SessionPersistence.SessionName == nil { + // SessionName is optional on the gateway-api, but envoy requires it + // so we generate the one here. + + // We generate a unique session name per route. + // `/` isn't allowed in the header key, so we just replace it with `-`. + sessionName = strings.ReplaceAll(irRouteDestinationName(httpRoute, ruleIdx), "/", "-") + } else { + sessionName = *rule.SessionPersistence.SessionName + } + + switch { + case rule.SessionPersistence.Type == nil || // Cookie-based session persistence is default. + *rule.SessionPersistence.Type == gwapiv1.CookieBasedSessionPersistence: + sessionPersistence = &ir.SessionPersistence{ + Cookie: &ir.CookieBasedSessionPersistence{ + Name: sessionName, + }, + } + if rule.SessionPersistence.AbsoluteTimeout != nil && + rule.SessionPersistence.CookieConfig != nil && rule.SessionPersistence.CookieConfig.LifetimeType != nil && + *rule.SessionPersistence.CookieConfig.LifetimeType == gwapiv1.PermanentCookieLifetimeType { + ttl, err := time.ParseDuration(string(*rule.SessionPersistence.AbsoluteTimeout)) + if err != nil { + return nil, err + } + sessionPersistence.Cookie.TTL = &metav1.Duration{Duration: ttl} + } + case *rule.SessionPersistence.Type == gwapiv1.HeaderBasedSessionPersistence: + sessionPersistence = &ir.SessionPersistence{ + Header: &ir.HeaderBasedSessionPersistence{ + Name: sessionName, + }, + } + default: + // Unknown session persistence type is specified. + return nil, fmt.Errorf("unknown session persistence type %s", *rule.SessionPersistence.Type) + } + } + // A rule is matched if any one of its matches // is satisfied (i.e. a logical "OR"), so generate // a unique Xds IR HTTPRoute per match. for matchIdx, match := range rule.Matches { irRoute := &ir.HTTPRoute{ - Name: irRouteName(httpRoute, ruleIdx, matchIdx), + Name: irRouteName(httpRoute, ruleIdx, matchIdx), + SessionPersistence: sessionPersistence, } processTimeout(irRoute, rule) @@ -699,6 +747,7 @@ func (t *Translator) processHTTPRouteParentRefListener(route RouteContext, route Mirrors: routeRoute.Mirrors, ExtensionRefs: routeRoute.ExtensionRefs, IsHTTP2: routeRoute.IsHTTP2, + SessionPersistence: routeRoute.SessionPersistence, } if routeRoute.Traffic != nil { hostRoute.Traffic = &ir.TrafficFeatures{ diff --git a/internal/ir/xds.go b/internal/ir/xds.go index 6ba04e5e20e..f2807da484a 100644 --- a/internal/ir/xds.go +++ b/internal/ir/xds.go @@ -565,6 +565,8 @@ type HTTPRoute struct { UseClientProtocol *bool `json:"useClientProtocol,omitempty" yaml:"useClientProtocol,omitempty"` // Metadata is used to enrich envoy route metadata with user and provider-specific information Metadata *ResourceMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` + // SessionPersistence holds the configuration for session persistence. + SessionPersistence *SessionPersistence `json:"sessionPersistence,omitempty" yaml:"sessionPersistence,omitempty"` } // DNS contains configuration options for DNS resolution. @@ -576,6 +578,33 @@ type DNS struct { RespectDNSTTL *bool `json:"respectDnsTtl,omitempty"` } +// SessionPersistence defines the desired state of SessionPersistence. +// +k8s:deepcopy-gen=true +type SessionPersistence struct { + // Cookie defines the configuration for cookie-based session persistence. + // Either Cookie or Header must be non-empty. + Cookie *CookieBasedSessionPersistence `json:"cookie,omitempty" yaml:"cookie,omitempty"` + // Header defines the configuration for header-based session persistence. + // Either Cookie or Header must be non-empty. + Header *HeaderBasedSessionPersistence `json:"header,omitempty" yaml:"header,omitempty"` +} + +// CookieBasedSessionPersistence defines the configuration for cookie-based session persistence. +// +k8s:deepcopy-gen=true +type CookieBasedSessionPersistence struct { + // Name defines the name of the persistent session token. + Name string `json:"name"` + + TTL *metav1.Duration `json:"ttl,omitempty" yaml:"ttl,omitempty"` +} + +// HeaderBasedSessionPersistence defines the configuration for header-based session persistence. +// +k8s:deepcopy-gen=true +type HeaderBasedSessionPersistence struct { + // Name defines the name of the persistent session token. + Name string `json:"name"` +} + // TrafficFeatures holds the information associated with the Backend Traffic Policy. // +k8s:deepcopy-gen=true type TrafficFeatures struct { diff --git a/internal/ir/zz_generated.deepcopy.go b/internal/ir/zz_generated.deepcopy.go index c51c386ee82..5e3398a0678 100644 --- a/internal/ir/zz_generated.deepcopy.go +++ b/internal/ir/zz_generated.deepcopy.go @@ -536,6 +536,26 @@ func (in *ConsistentHash) DeepCopy() *ConsistentHash { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CookieBasedSessionPersistence) DeepCopyInto(out *CookieBasedSessionPersistence) { + *out = *in + if in.TTL != nil { + in, out := &in.TTL, &out.TTL + *out = new(v1.Duration) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CookieBasedSessionPersistence. +func (in *CookieBasedSessionPersistence) DeepCopy() *CookieBasedSessionPersistence { + if in == nil { + return nil + } + out := new(CookieBasedSessionPersistence) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CoreListenerDetails) DeepCopyInto(out *CoreListenerDetails) { *out = *in @@ -1363,6 +1383,11 @@ func (in *HTTPRoute) DeepCopyInto(out *HTTPRoute) { *out = new(ResourceMetadata) (*in).DeepCopyInto(*out) } + if in.SessionPersistence != nil { + in, out := &in.SessionPersistence, &out.SessionPersistence + *out = new(SessionPersistence) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPRoute. @@ -1420,6 +1445,21 @@ func (in *HTTPWasmCode) DeepCopy() *HTTPWasmCode { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HeaderBasedSessionPersistence) DeepCopyInto(out *HeaderBasedSessionPersistence) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HeaderBasedSessionPersistence. +func (in *HeaderBasedSessionPersistence) DeepCopy() *HeaderBasedSessionPersistence { + if in == nil { + return nil + } + out := new(HeaderBasedSessionPersistence) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HeaderSettings) DeepCopyInto(out *HeaderSettings) { *out = *in @@ -2349,6 +2389,31 @@ func (in *SecurityFeatures) DeepCopy() *SecurityFeatures { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SessionPersistence) DeepCopyInto(out *SessionPersistence) { + *out = *in + if in.Cookie != nil { + in, out := &in.Cookie, &out.Cookie + *out = new(CookieBasedSessionPersistence) + (*in).DeepCopyInto(*out) + } + if in.Header != nil { + in, out := &in.Header, &out.Header + *out = new(HeaderBasedSessionPersistence) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SessionPersistence. +func (in *SessionPersistence) DeepCopy() *SessionPersistence { + if in == nil { + return nil + } + out := new(SessionPersistence) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SlowStart) DeepCopyInto(out *SlowStart) { *out = *in diff --git a/internal/xds/translator/httpfilters.go b/internal/xds/translator/httpfilters.go index ad5789fb6ff..1b994fba669 100644 --- a/internal/xds/translator/httpfilters.go +++ b/internal/xds/translator/httpfilters.go @@ -114,8 +114,10 @@ func newOrderedHTTPFilter(filter *hcmv3.HttpFilter) *OrderedHTTPFilter { order = 5 case isFilterType(filter, egv1a1.EnvoyFilterJWTAuthn): order = 6 + case isFilterType(filter, egv1a1.EnvoyFilterSessionPersistence): + order = 7 case isFilterType(filter, egv1a1.EnvoyFilterExtProc): - order = 7 + mustGetFilterIndex(filter.Name) + order = 8 + mustGetFilterIndex(filter.Name) case isFilterType(filter, egv1a1.EnvoyFilterWasm): order = 100 + mustGetFilterIndex(filter.Name) case isFilterType(filter, egv1a1.EnvoyFilterRBAC): diff --git a/internal/xds/translator/session_persistence.go b/internal/xds/translator/session_persistence.go new file mode 100644 index 00000000000..703e553ce47 --- /dev/null +++ b/internal/xds/translator/session_persistence.go @@ -0,0 +1,169 @@ +// Copyright Envoy Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +package translator + +import ( + "errors" + "fmt" + "strings" + + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" + statefulsessionv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/http/stateful_session/v3" + hcmv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" + cookiev3 "github.com/envoyproxy/go-control-plane/envoy/extensions/http/stateful_session/cookie/v3" + headerv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/http/stateful_session/header/v3" + httpv3 "github.com/envoyproxy/go-control-plane/envoy/type/http/v3" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/durationpb" + + egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" + "github.com/envoyproxy/gateway/internal/ir" + "github.com/envoyproxy/gateway/internal/xds/types" +) + +const ( + cookieConfigName = "envoy.http.stateful_session.cookie" + headerConfigName = "envoy.http.stateful_session.header" +) + +type sessionPersistence struct{} + +func init() { + registerHTTPFilter(&sessionPersistence{}) +} + +var _ httpFilter = &sessionPersistence{} + +// patchHCM patches the HttpConnectionManager with the filter. +// Note: this method may be called multiple times for the same filter, please +// make sure to avoid duplicate additions of the same filter. +func (s *sessionPersistence) patchHCM(mgr *hcmv3.HttpConnectionManager, irListener *ir.HTTPListener) error { + if mgr == nil { + return errors.New("hcm is nil") + } + + if irListener == nil { + return errors.New("ir listener is nil") + } + + for _, route := range irListener.Routes { + sp := route.SessionPersistence + if sp == nil { + continue + } + + if hcmContainsFilter(mgr, perRouteFilterName(egv1a1.EnvoyFilterSessionPersistence, route.Name)) { + continue + } + + var sessionCfg proto.Message + var configName string + switch { + case sp.Cookie != nil: + configName = cookieConfigName + sessionCfg = &cookiev3.CookieBasedSessionState{ + Cookie: &httpv3.Cookie{ + Name: sp.Cookie.Name, + Path: routePathToCookiePath(route.PathMatch), + Ttl: durationpb.New(sp.Cookie.TTL.Duration), + }, + } + case sp.Header != nil: + configName = headerConfigName + sessionCfg = &headerv3.HeaderBasedSessionState{ + Name: sp.Header.Name, + } + } + + sessionCfgAny, err := anypb.New(sessionCfg) + if err != nil { + return fmt.Errorf("failed to marshal %s config: %w", egv1a1.EnvoyFilterSessionPersistence.String(), err) + } + + cfg := &statefulsessionv3.StatefulSession{ + SessionState: &corev3.TypedExtensionConfig{ + Name: configName, + TypedConfig: sessionCfgAny, + }, + } + + cfgAny, err := anypb.New(cfg) + if err != nil { + return fmt.Errorf("failed to marshal %s config: %w", egv1a1.EnvoyFilterSessionPersistence.String(), err) + } + + mgr.HttpFilters = append(mgr.HttpFilters, &hcmv3.HttpFilter{ + Name: perRouteFilterName(egv1a1.EnvoyFilterSessionPersistence, route.Name), + Disabled: true, + ConfigType: &hcmv3.HttpFilter_TypedConfig{ + TypedConfig: cfgAny, + }, + }) + } + + return nil +} + +func routePathToCookiePath(path *ir.StringMatch) string { + if path == nil { + return "/" + } + switch { + case path.Exact != nil: + return *path.Exact + case path.Prefix != nil: + return *path.Prefix + case path.SafeRegex != nil: + return getLongestNonRegexPrefix(*path.SafeRegex) + } + + // Shouldn't reach here because the path should be either of the above three kinds. + return "/" +} + +// getLongestNonRegexPrefix takes a regex path and returns the longest non-regex prefix. +// > 3. For an xRoute using a path that is a regex, the Path should be set to the longest non-regex prefix +// (.e.g. if the path is /p1/p2/*/p3 and the request path was /p1/p2/foo/p3, then the cookie path would be /p1/p2). +// https://gateway-api.sigs.k8s.io/geps/gep-1619/#path +func getLongestNonRegexPrefix(path string) string { + parts := strings.Split(path, "/") + var longestNonRegexPrefix []string + for _, part := range parts { + if part == "*" || strings.Contains(part, "*") { + break + } + longestNonRegexPrefix = append(longestNonRegexPrefix, part) + } + + return strings.Join(longestNonRegexPrefix, "/") +} + +// patchRoute patches the provide Route with a filter's Route level configuration. +func (s *sessionPersistence) patchRoute(route *routev3.Route, irRoute *ir.HTTPRoute) error { + if route == nil { + return errors.New("xds route is nil") + } + if irRoute == nil { + return errors.New("ir route is nil") + } + if irRoute.SessionPersistence == nil { + return nil + } + + if err := enableFilterOnRoute(route, perRouteFilterName(egv1a1.EnvoyFilterSessionPersistence, route.Name)); err != nil { + return err + } + + return nil +} + +// patchResources adds all the other needed resources referenced by this +// filter to the resource version table. +func (s *sessionPersistence) patchResources(tCtx *types.ResourceVersionTable, routes []*ir.HTTPRoute) error { + return nil +} diff --git a/internal/xds/translator/testdata/in/xds-ir/http-route-session-persistence.yaml b/internal/xds/translator/testdata/in/xds-ir/http-route-session-persistence.yaml new file mode 100644 index 00000000000..536c5ad50cb --- /dev/null +++ b/internal/xds/translator/testdata/in/xds-ir/http-route-session-persistence.yaml @@ -0,0 +1,66 @@ +http: +- name: "first-listener" + address: "0.0.0.0" + port: 10080 + hostnames: + - "*" + path: + mergeSlashes: true + escapedSlashesAction: UnescapeAndRedirect + routes: + - name: "header-based-session-persistence-route" + hostname: "*" + pathMatch: + safeRegex: "/v1/.*" + sessionPersistence: + header: { + name: "session-header" + } + destination: + name: "regex-route-dest" + settings: + - endpoints: + - host: "1.2.3.4" + port: 50000 + - name: "cookie-based-session-persistence-route-regex" + hostname: "*" + pathMatch: + safeRegex: "/v1/.*/hoge" + sessionPersistence: + cookie: + name: "session-header" + ttl: "1h" + destination: + name: "regex-route-dest" + settings: + - endpoints: + - host: "1.2.3.4" + port: 50000 + - name: "cookie-based-session-persistence-route-prefix" + hostname: "*" + pathMatch: + prefix: "/v2/" + sessionPersistence: + cookie: + name: "session-header" + ttl: "1h" + destination: + name: "regex-route-dest" + settings: + - endpoints: + - host: "1.2.3.4" + port: 50000 + - name: "cookie-based-session-persistence-route-exact" + hostname: "*" + pathMatch: + exact: "/v3/user" + sessionPersistence: + cookie: + name: "session-cookie" + ttl: "1h" + destination: + name: "regex-route-dest" + settings: + - endpoints: + - host: "1.2.3.4" + port: 50000 diff --git a/internal/xds/translator/testdata/out/xds-ir/http-route-session-persistence.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/http-route-session-persistence.clusters.yaml new file mode 100644 index 00000000000..0f75e67e278 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/http-route-session-persistence.clusters.yaml @@ -0,0 +1,17 @@ +- circuitBreakers: + thresholds: + - maxRetries: 1024 + commonLbConfig: + localityWeightedLbConfig: {} + connectTimeout: 10s + dnsLookupFamily: V4_ONLY + edsClusterConfig: + edsConfig: + ads: {} + resourceApiVersion: V3 + serviceName: regex-route-dest + lbPolicy: LEAST_REQUEST + name: regex-route-dest + outlierDetection: {} + perConnectionBufferLimitBytes: 32768 + type: EDS diff --git a/internal/xds/translator/testdata/out/xds-ir/http-route-session-persistence.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/http-route-session-persistence.endpoints.yaml new file mode 100644 index 00000000000..b36ee450059 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/http-route-session-persistence.endpoints.yaml @@ -0,0 +1,12 @@ +- clusterName: regex-route-dest + endpoints: + - lbEndpoints: + - endpoint: + address: + socketAddress: + address: 1.2.3.4 + portValue: 50000 + loadBalancingWeight: 1 + loadBalancingWeight: 1 + locality: + region: regex-route-dest/backend/0 diff --git a/internal/xds/translator/testdata/out/xds-ir/http-route-session-persistence.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/http-route-session-persistence.listeners.yaml new file mode 100644 index 00000000000..f29e11a27a4 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/http-route-session-persistence.listeners.yaml @@ -0,0 +1,80 @@ +- address: + socketAddress: + address: 0.0.0.0 + portValue: 10080 + defaultFilterChain: + filters: + - name: envoy.filters.network.http_connection_manager + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + commonHttpProtocolOptions: + headersWithUnderscoresAction: REJECT_REQUEST + http2ProtocolOptions: + initialConnectionWindowSize: 1048576 + initialStreamWindowSize: 65536 + maxConcurrentStreams: 100 + httpFilters: + - disabled: true + name: envoy.filters.http.stateful_session/header-based-session-persistence-route + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.stateful_session.v3.StatefulSession + sessionState: + name: envoy.http.stateful_session.header + typedConfig: + '@type': type.googleapis.com/envoy.extensions.http.stateful_session.header.v3.HeaderBasedSessionState + name: session-header + - disabled: true + name: envoy.filters.http.stateful_session/cookie-based-session-persistence-route-regex + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.stateful_session.v3.StatefulSession + sessionState: + name: envoy.http.stateful_session.cookie + typedConfig: + '@type': type.googleapis.com/envoy.extensions.http.stateful_session.cookie.v3.CookieBasedSessionState + cookie: + name: session-header + path: /v1 + ttl: 3600s + - disabled: true + name: envoy.filters.http.stateful_session/cookie-based-session-persistence-route-prefix + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.stateful_session.v3.StatefulSession + sessionState: + name: envoy.http.stateful_session.cookie + typedConfig: + '@type': type.googleapis.com/envoy.extensions.http.stateful_session.cookie.v3.CookieBasedSessionState + cookie: + name: session-header + path: /v2/ + ttl: 3600s + - disabled: true + name: envoy.filters.http.stateful_session/cookie-based-session-persistence-route-exact + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.stateful_session.v3.StatefulSession + sessionState: + name: envoy.http.stateful_session.cookie + typedConfig: + '@type': type.googleapis.com/envoy.extensions.http.stateful_session.cookie.v3.CookieBasedSessionState + cookie: + name: session-cookie + path: /v3/user + ttl: 3600s + - name: envoy.filters.http.router + typedConfig: + '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + suppressEnvoyHeaders: true + mergeSlashes: true + normalizePath: true + pathWithEscapedSlashesAction: UNESCAPE_AND_REDIRECT + rds: + configSource: + ads: {} + resourceApiVersion: V3 + routeConfigName: first-listener + serverHeaderTransformation: PASS_THROUGH + statPrefix: http-10080 + useRemoteAddress: true + name: first-listener + drainType: MODIFY_ONLY + name: first-listener + perConnectionBufferLimitBytes: 32768 diff --git a/internal/xds/translator/testdata/out/xds-ir/http-route-session-persistence.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/http-route-session-persistence.routes.yaml new file mode 100644 index 00000000000..c5450601be4 --- /dev/null +++ b/internal/xds/translator/testdata/out/xds-ir/http-route-session-persistence.routes.yaml @@ -0,0 +1,53 @@ +- ignorePortInHostMatching: true + name: first-listener + virtualHosts: + - domains: + - '*' + name: first-listener/* + routes: + - match: + safeRegex: + regex: /v1/.* + name: header-based-session-persistence-route + route: + cluster: regex-route-dest + upgradeConfigs: + - upgradeType: websocket + typedPerFilterConfig: + envoy.filters.http.stateful_session/header-based-session-persistence-route: + '@type': type.googleapis.com/envoy.config.route.v3.FilterConfig + config: {} + - match: + safeRegex: + regex: /v1/.*/hoge + name: cookie-based-session-persistence-route-regex + route: + cluster: regex-route-dest + upgradeConfigs: + - upgradeType: websocket + typedPerFilterConfig: + envoy.filters.http.stateful_session/cookie-based-session-persistence-route-regex: + '@type': type.googleapis.com/envoy.config.route.v3.FilterConfig + config: {} + - match: + pathSeparatedPrefix: /v2 + name: cookie-based-session-persistence-route-prefix + route: + cluster: regex-route-dest + upgradeConfigs: + - upgradeType: websocket + typedPerFilterConfig: + envoy.filters.http.stateful_session/cookie-based-session-persistence-route-prefix: + '@type': type.googleapis.com/envoy.config.route.v3.FilterConfig + config: {} + - match: + path: /v3/user + name: cookie-based-session-persistence-route-exact + route: + cluster: regex-route-dest + upgradeConfigs: + - upgradeType: websocket + typedPerFilterConfig: + envoy.filters.http.stateful_session/cookie-based-session-persistence-route-exact: + '@type': type.googleapis.com/envoy.config.route.v3.FilterConfig + config: {} diff --git a/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md index 516864db274..2d2b75a3da3 100644 --- a/site/content/en/latest/api/extension_types.md +++ b/site/content/en/latest/api/extension_types.md @@ -964,6 +964,7 @@ _Appears in:_ | `envoy.filters.http.basic_auth` | EnvoyFilterBasicAuth defines the Envoy HTTP basic authentication filter.
| | `envoy.filters.http.oauth2` | EnvoyFilterOAuth2 defines the Envoy HTTP OAuth2 filter.
| | `envoy.filters.http.jwt_authn` | EnvoyFilterJWTAuthn defines the Envoy HTTP JWT authentication filter.
| +| `envoy.filters.http.stateful_session` | EnvoyFilterSessionPersistence defines the Envoy HTTP session persistence filter.
| | `envoy.filters.http.ext_proc` | EnvoyFilterExtProc defines the Envoy HTTP external process filter.
| | `envoy.filters.http.wasm` | EnvoyFilterWasm defines the Envoy HTTP WebAssembly filter.
| | `envoy.filters.http.rbac` | EnvoyFilterRBAC defines the Envoy RBAC filter.
| @@ -1422,7 +1423,7 @@ _Appears in:_ | `extraArgs` | _string array_ | false | ExtraArgs defines additional command line options that are provided to Envoy.
More info: https://www.envoyproxy.io/docs/envoy/latest/operations/cli#command-line-options
Note: some command line options are used internally(e.g. --log-level) so they cannot be provided here. | | `mergeGateways` | _boolean_ | false | MergeGateways defines if Gateway resources should be merged onto the same Envoy Proxy Infrastructure.
Setting this field to true would merge all Gateway Listeners under the parent Gateway Class.
This means that the port, protocol and hostname tuple must be unique for every listener.
If a duplicate listener is detected, the newer listener (based on timestamp) will be rejected and its status will be updated with a "Accepted=False" condition. | | `shutdown` | _[ShutdownConfig](#shutdownconfig)_ | false | Shutdown defines configuration for graceful envoy shutdown process. | -| `filterOrder` | _[FilterPosition](#filterposition) array_ | false | FilterOrder defines the order of filters in the Envoy proxy's HTTP filter chain.
The FilterPosition in the list will be applied in the order they are defined.
If unspecified, the default filter order is applied.
Default filter order is:

- envoy.filters.http.health_check

- envoy.filters.http.fault

- envoy.filters.http.cors

- envoy.filters.http.ext_authz

- envoy.filters.http.basic_auth

- envoy.filters.http.oauth2

- envoy.filters.http.jwt_authn

- envoy.filters.http.ext_proc

- envoy.filters.http.wasm

- envoy.filters.http.rbac

- envoy.filters.http.local_ratelimit

- envoy.filters.http.ratelimit

- envoy.filters.http.router

Note: "envoy.filters.http.router" cannot be reordered, it's always the last filter in the chain. | +| `filterOrder` | _[FilterPosition](#filterposition) array_ | false | FilterOrder defines the order of filters in the Envoy proxy's HTTP filter chain.
The FilterPosition in the list will be applied in the order they are defined.
If unspecified, the default filter order is applied.
Default filter order is:

- envoy.filters.http.health_check

- envoy.filters.http.fault

- envoy.filters.http.cors

- envoy.filters.http.ext_authz

- envoy.filters.http.basic_auth

- envoy.filters.http.oauth2

- envoy.filters.http.jwt_authn

- envoy.filters.http.stateful_session

- envoy.filters.http.ext_proc

- envoy.filters.http.wasm

- envoy.filters.http.rbac

- envoy.filters.http.local_ratelimit

- envoy.filters.http.ratelimit

- envoy.filters.http.router

Note: "envoy.filters.http.router" cannot be reordered, it's always the last filter in the chain. | | `backendTLS` | _[BackendTLSConfig](#backendtlsconfig)_ | false | BackendTLS is the TLS configuration for the Envoy proxy to use when connecting to backends.
These settings are applied on backends for which TLS policies are specified. | diff --git a/site/content/zh/latest/api/extension_types.md b/site/content/zh/latest/api/extension_types.md index 516864db274..2d2b75a3da3 100644 --- a/site/content/zh/latest/api/extension_types.md +++ b/site/content/zh/latest/api/extension_types.md @@ -964,6 +964,7 @@ _Appears in:_ | `envoy.filters.http.basic_auth` | EnvoyFilterBasicAuth defines the Envoy HTTP basic authentication filter.
| | `envoy.filters.http.oauth2` | EnvoyFilterOAuth2 defines the Envoy HTTP OAuth2 filter.
| | `envoy.filters.http.jwt_authn` | EnvoyFilterJWTAuthn defines the Envoy HTTP JWT authentication filter.
| +| `envoy.filters.http.stateful_session` | EnvoyFilterSessionPersistence defines the Envoy HTTP session persistence filter.
| | `envoy.filters.http.ext_proc` | EnvoyFilterExtProc defines the Envoy HTTP external process filter.
| | `envoy.filters.http.wasm` | EnvoyFilterWasm defines the Envoy HTTP WebAssembly filter.
| | `envoy.filters.http.rbac` | EnvoyFilterRBAC defines the Envoy RBAC filter.
| @@ -1422,7 +1423,7 @@ _Appears in:_ | `extraArgs` | _string array_ | false | ExtraArgs defines additional command line options that are provided to Envoy.
More info: https://www.envoyproxy.io/docs/envoy/latest/operations/cli#command-line-options
Note: some command line options are used internally(e.g. --log-level) so they cannot be provided here. | | `mergeGateways` | _boolean_ | false | MergeGateways defines if Gateway resources should be merged onto the same Envoy Proxy Infrastructure.
Setting this field to true would merge all Gateway Listeners under the parent Gateway Class.
This means that the port, protocol and hostname tuple must be unique for every listener.
If a duplicate listener is detected, the newer listener (based on timestamp) will be rejected and its status will be updated with a "Accepted=False" condition. | | `shutdown` | _[ShutdownConfig](#shutdownconfig)_ | false | Shutdown defines configuration for graceful envoy shutdown process. | -| `filterOrder` | _[FilterPosition](#filterposition) array_ | false | FilterOrder defines the order of filters in the Envoy proxy's HTTP filter chain.
The FilterPosition in the list will be applied in the order they are defined.
If unspecified, the default filter order is applied.
Default filter order is:

- envoy.filters.http.health_check

- envoy.filters.http.fault

- envoy.filters.http.cors

- envoy.filters.http.ext_authz

- envoy.filters.http.basic_auth

- envoy.filters.http.oauth2

- envoy.filters.http.jwt_authn

- envoy.filters.http.ext_proc

- envoy.filters.http.wasm

- envoy.filters.http.rbac

- envoy.filters.http.local_ratelimit

- envoy.filters.http.ratelimit

- envoy.filters.http.router

Note: "envoy.filters.http.router" cannot be reordered, it's always the last filter in the chain. | +| `filterOrder` | _[FilterPosition](#filterposition) array_ | false | FilterOrder defines the order of filters in the Envoy proxy's HTTP filter chain.
The FilterPosition in the list will be applied in the order they are defined.
If unspecified, the default filter order is applied.
Default filter order is:

- envoy.filters.http.health_check

- envoy.filters.http.fault

- envoy.filters.http.cors

- envoy.filters.http.ext_authz

- envoy.filters.http.basic_auth

- envoy.filters.http.oauth2

- envoy.filters.http.jwt_authn

- envoy.filters.http.stateful_session

- envoy.filters.http.ext_proc

- envoy.filters.http.wasm

- envoy.filters.http.rbac

- envoy.filters.http.local_ratelimit

- envoy.filters.http.ratelimit

- envoy.filters.http.router

Note: "envoy.filters.http.router" cannot be reordered, it's always the last filter in the chain. | | `backendTLS` | _[BackendTLSConfig](#backendtlsconfig)_ | false | BackendTLS is the TLS configuration for the Envoy proxy to use when connecting to backends.
These settings are applied on backends for which TLS policies are specified. | diff --git a/test/e2e/testdata/cookie-based-session-persistence.yaml b/test/e2e/testdata/cookie-based-session-persistence.yaml new file mode 100644 index 00000000000..60819e18098 --- /dev/null +++ b/test/e2e/testdata/cookie-based-session-persistence.yaml @@ -0,0 +1,29 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: cookie-based-session-persistence + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: same-namespace + rules: + - matches: + - path: + type: PathPrefix + value: /v1 + backendRefs: + - name: infra-backend-v1 + port: 8080 + - matches: + - path: + type: PathPrefix + value: /v2 + backendRefs: + - name: infra-backend-v1 + port: 8080 + sessionPersistence: + sessionName: Session-A + type: Cookie + absoluteTimeout: 10s + cookieConfig: + lifetimeType: Permanent diff --git a/test/e2e/testdata/header-based-session-persistence.yaml b/test/e2e/testdata/header-based-session-persistence.yaml new file mode 100644 index 00000000000..4c6030a99df --- /dev/null +++ b/test/e2e/testdata/header-based-session-persistence.yaml @@ -0,0 +1,30 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: header-based-session-persistence + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: same-namespace + rules: + - matches: + - path: + type: PathPrefix + value: /v1 + backendRefs: + - name: infra-backend-v1 + port: 8080 + - matches: + - path: + type: PathPrefix + value: /v2 + backendRefs: + - name: infra-backend-v1 + port: 8080 + sessionPersistence: + sessionName: Session-A + type: Header + # Actually, absoluteTimeout is not necessary for Header based session persistence. + # But, we have to add it, otherwise the gateway-api validation (mistakenly) rejects it. + # https://github.com/kubernetes-sigs/gateway-api/issues/3214 + absoluteTimeout: 10s diff --git a/test/e2e/tests/session_persistence.go b/test/e2e/tests/session_persistence.go new file mode 100644 index 00000000000..7c1d90880ab --- /dev/null +++ b/test/e2e/tests/session_persistence.go @@ -0,0 +1,189 @@ +// Copyright Envoy Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +//go:build e2e +// +build e2e + +package tests + +import ( + "fmt" + "net/http" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "k8s.io/apimachinery/pkg/types" + httputils "sigs.k8s.io/gateway-api/conformance/utils/http" + "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" + "sigs.k8s.io/gateway-api/conformance/utils/suite" +) + +func init() { + ConformanceTests = append(ConformanceTests, HeaderBasedSessionPersistenceTest) + ConformanceTests = append(ConformanceTests, CookieBasedSessionPersistenceTest) +} + +var HeaderBasedSessionPersistenceTest = suite.ConformanceTest{ + ShortName: "HeaderBasedSessionPersistence", + Description: "Test that the session persistence filter is correctly configured with header based session persistence", + Manifests: []string{"testdata/header-based-session-persistence.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + t.Run("traffic is routed based on header based session persistence", func(t *testing.T) { + ns := "gateway-conformance-infra" + routeNN := types.NamespacedName{Name: "header-based-session-persistence", Namespace: ns} + gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN) + req := httputils.MakeRequest(t, &httputils.ExpectedResponse{ + Request: httputils.Request{ + Path: "/v2", + }, + }, gwAddr, "HTTP", "http") + + pod := "" + // We make 10 requests to the gateway and expect them to be routed to the same pod. + for i := 0; i < 10; i++ { + captReq, res, err := suite.RoundTripper.CaptureRoundTrip(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + + if i == 0 { + // First request, capture the pod name and header. + sessionHeader, ok := res.Headers["Session-A"] + if !ok { + t.Fatalf("expected header Session-A to be set: %v", res.Headers) + } + + if captReq.Pod == "" { + t.Fatalf("expected pod to be set") + } + pod = captReq.Pod + req.Headers["Session-A"] = sessionHeader + continue + } + + t.Logf("request is received from pod %s", captReq.Pod) + + if captReq.Pod != pod { + t.Fatalf("expected pod to be the same as previous requests") + } + } + }) + t.Run("session persistence is configured per route", func(t *testing.T) { + ns := "gateway-conformance-infra" + routeNN := types.NamespacedName{Name: "header-based-session-persistence", Namespace: ns} + gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN) + req := httputils.MakeRequest(t, &httputils.ExpectedResponse{ + Request: httputils.Request{ + // /v1 path does not have the session persistence. + Path: "/v1", + }, + }, gwAddr, "HTTP", "http") + + _, res, err := suite.RoundTripper.CaptureRoundTrip(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + + if h, ok := res.Headers["Session-A"]; ok { + t.Fatalf("expected header Session-A to not be set: %v", h) + } + }) + }, +} + +var CookieBasedSessionPersistenceTest = suite.ConformanceTest{ + ShortName: "CookieBasedSessionPersistence", + Description: "Test that the session persistence filter is correctly configured with cookie based session persistence", + Manifests: []string{"testdata/cookie-based-session-persistence.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + t.Run("traffic is routed based on cookie based session persistence", func(t *testing.T) { + ns := "gateway-conformance-infra" + routeNN := types.NamespacedName{Name: "cookie-based-session-persistence", Namespace: ns} + gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN) + req := httputils.MakeRequest(t, &httputils.ExpectedResponse{ + Request: httputils.Request{ + Path: "/v2", + }, + }, gwAddr, "HTTP", "http") + + pod := "" + // We make 10 requests to the gateway and expect them to be routed to the same pod. + for i := 0; i < 10; i++ { + captReq, res, err := suite.RoundTripper.CaptureRoundTrip(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + + if i == 0 { + // First request, capture the pod name and cookie. + if captReq.Pod == "" { + t.Fatalf("expected pod to be set") + } + + cookie, err := parseCookie(res.Headers, "Session-A") + if err != nil { + t.Fatalf("failed to parse cookie: %v", err) + } + + // Check the cookie is set correctly. + if diff := cmp.Diff(cookie, &http.Cookie{ + Name: "Session-A", + MaxAge: 10, + Path: "/v2", + HttpOnly: true, + }, cmpopts.IgnoreFields(http.Cookie{}, "Value", "Raw"), // Ignore the value as it is random. + ); diff != "" { + t.Fatalf("unexpected cookie: %v", diff) + } + + pod = captReq.Pod + req.Headers["Cookie"] = []string{fmt.Sprintf("Session-A=%s", cookie.Value)} + continue + } + + t.Logf("request is received from pod %s", captReq.Pod) + + if captReq.Pod != pod { + t.Fatalf("expected pod to be the same as previous requests") + } + } + }) + t.Run("session persistence is configured per route", func(t *testing.T) { + ns := "gateway-conformance-infra" + routeNN := types.NamespacedName{Name: "cookie-based-session-persistence", Namespace: ns} + gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN) + req := httputils.MakeRequest(t, &httputils.ExpectedResponse{ + Request: httputils.Request{ + // /v1 path does not have the session persistence. + Path: "/v1", + }, + }, gwAddr, "HTTP", "http") + + _, res, err := suite.RoundTripper.CaptureRoundTrip(req) + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + + if _, ok := res.Headers["Set-Cookie"]; ok { + t.Fatal("expected the envoy not to response set-cookie back") + } + }) + }, +} + +func parseCookie(headers map[string][]string, cookieName string) (*http.Cookie, error) { + parser := &http.Response{Header: headers} + for _, c := range parser.Cookies() { + if c.Name == cookieName { + return c, nil + } + } + return nil, fmt.Errorf("cookie %s not found: headers: %v", cookieName, headers) +}