diff --git a/apis/gateway/v1beta1/listenerruleconfig_types.go b/apis/gateway/v1beta1/listenerruleconfig_types.go index ba090c91b4..dd80176492 100644 --- a/apis/gateway/v1beta1/listenerruleconfig_types.go +++ b/apis/gateway/v1beta1/listenerruleconfig_types.go @@ -12,6 +12,52 @@ const ( ListenerRuleConditionFieldSourceIP ListenerRuleConditionField = "source-ip" ) +// ListenerRuleTransformType defines the type of transform for the listener rule +// +kubebuilder:validation:Enum=host-header-rewrite +type ListenerRuleTransformType string + +const ( + ListenerRuleTransformTypeHostHeaderRewrite ListenerRuleTransformType = "host-header-rewrite" +) + +// ListenerRuleRewriteConfig defines a regex-based rewrite rule +type ListenerRuleRewriteConfig struct { + // Regex expression to match against the header value + // +kubebuilder:validation:MinLength=1 + Regex string `json:"regex"` + // Replacement expression to use when the regex matches. + // When SourceHeader is specified, this field supports referencing the matched value + // from the source header using regex capture groups (e.g., "$1"). + Replace string `json:"replace"` +} + +// ListenerRuleHostHeaderRewriteConfig defines configuration for rewriting the Host header +type ListenerRuleHostHeaderRewriteConfig struct { + // Rewrites defines the regex-based rewrite rules for the Host header. + // +kubebuilder:validation:MinItems=1 + Rewrites []ListenerRuleRewriteConfig `json:"rewrites"` + + // SourceHeader specifies the name of the incoming request header whose value + // should be used to rewrite the Host header. When specified, the Host header + // will be set based on the value of this source header, processed through + // the regex/replace rules defined in Rewrites. + // For example, setting SourceHeader to "X-School-Domain" will use the value + // of the X-School-Domain header as input for the rewrite rules. + // +optional + SourceHeader *string `json:"sourceHeader,omitempty"` +} + +// ListenerRuleTransform defines a transform to apply to the request +// +kubebuilder:validation:XValidation:rule="has(self.type) && self.type == 'host-header-rewrite' ? has(self.hostHeaderRewriteConfig) : !has(self.hostHeaderRewriteConfig)",message="hostHeaderRewriteConfig must be specified only when type is 'host-header-rewrite'" +type ListenerRuleTransform struct { + // The type of transform + Type ListenerRuleTransformType `json:"type"` + + // Information for a host header rewrite transform. + // +optional + HostHeaderRewriteConfig *ListenerRuleHostHeaderRewriteConfig `json:"hostHeaderRewriteConfig,omitempty"` +} + // AuthenticateCognitoActionConditionalBehaviorEnum defines the behavior when a user is not authenticated // +kubebuilder:validation:Enum=deny;allow;authenticate type AuthenticateCognitoActionConditionalBehaviorEnum string @@ -344,6 +390,13 @@ type ListenerRuleConfigurationSpec struct { // +kubebuilder:validation:MinItems=1 Conditions []ListenerRuleCondition `json:"conditions,omitempty"` + // Transforms defines the set of transforms to apply to the request before forwarding. + // Transforms allow modifying request attributes such as the Host header + // using regex-based rewrite rules. + // +optional + // +kubebuilder:validation:MaxItems=1 + Transforms []ListenerRuleTransform `json:"transforms,omitempty"` + // Tags are the AWS resource tags to be applied to all AWS resources created for this rule. // +optional Tags *map[string]string `json:"tags,omitempty"` diff --git a/pkg/gateway/model/model_build_listener.go b/pkg/gateway/model/model_build_listener.go index 08c0ca887c..c5f8ed99e4 100644 --- a/pkg/gateway/model/model_build_listener.go +++ b/pkg/gateway/model/model_build_listener.go @@ -312,8 +312,11 @@ func (l listenerBuilderImpl) buildListenerRules(ctx context.Context, stack core. albRules = append(albRules, elbv2model.Rule{ Conditions: conditionsList, Actions: actions, - Transforms: routeutils.BuildRoutingRuleTransforms(route, ruleWithPrecedence), - Tags: tags, + Transforms: mergeTransforms( + routeutils.BuildRoutingRuleTransforms(route, ruleWithPrecedence), + routeutils.BuildListenerRuleConfigTransforms(ruleWithPrecedence), + ), + Tags: tags, }) } @@ -334,6 +337,35 @@ func (l listenerBuilderImpl) buildListenerRules(ctx context.Context, stack core. return secrets, nil } +// mergeTransforms combines transforms from Gateway API HTTPRoute filters and +// ListenerRuleConfiguration CRD. CRD transforms take precedence: if the CRD +// specifies a transform of the same type as one from the HTTPRoute, the CRD +// version replaces it. +func mergeTransforms(routeTransforms, crdTransforms []elbv2model.Transform) []elbv2model.Transform { + if len(crdTransforms) == 0 { + return routeTransforms + } + if len(routeTransforms) == 0 { + return crdTransforms + } + + // Build a set of transform types from CRD transforms + crdTypes := make(map[elbv2model.TransformType]bool, len(crdTransforms)) + for _, t := range crdTransforms { + crdTypes[t.Type] = true + } + + // Keep route transforms that are not overridden by CRD + merged := make([]elbv2model.Transform, 0, len(routeTransforms)+len(crdTransforms)) + for _, t := range routeTransforms { + if !crdTypes[t.Type] { + merged = append(merged, t) + } + } + merged = append(merged, crdTransforms...) + return merged +} + func (l listenerBuilderImpl) buildListenerTags(lbCfg elbv2gw.LoadBalancerConfiguration) (map[string]string, error) { // We dont have tags at listener level cfg. Hence we add all the load balancer level tags to listeners. return l.tagHelper.getLoadBalancerTags(lbCfg) diff --git a/pkg/gateway/model/model_build_listener_test.go b/pkg/gateway/model/model_build_listener_test.go index 8b2ffb58c8..538c766bf9 100644 --- a/pkg/gateway/model/model_build_listener_test.go +++ b/pkg/gateway/model/model_build_listener_test.go @@ -2461,3 +2461,148 @@ func TestMergeProtocols_WithQuic(t *testing.T) { }) } } + +func Test_mergeTransforms(t *testing.T) { + testCases := []struct { + name string + routeTransforms []elbv2model.Transform + crdTransforms []elbv2model.Transform + expected []elbv2model.Transform + }{ + { + name: "both nil", + routeTransforms: nil, + crdTransforms: nil, + expected: nil, + }, + { + name: "crd transforms nil, route transforms returned", + routeTransforms: []elbv2model.Transform{ + { + Type: elbv2model.TransformTypeHostHeaderRewrite, + HostHeaderRewriteConfig: &elbv2model.RewriteConfigObject{ + Rewrites: []elbv2model.RewriteConfig{ + {Regex: ".*", Replace: "route.example.com"}, + }, + }, + }, + }, + crdTransforms: nil, + expected: []elbv2model.Transform{ + { + Type: elbv2model.TransformTypeHostHeaderRewrite, + HostHeaderRewriteConfig: &elbv2model.RewriteConfigObject{ + Rewrites: []elbv2model.RewriteConfig{ + {Regex: ".*", Replace: "route.example.com"}, + }, + }, + }, + }, + }, + { + name: "route transforms nil, crd transforms returned", + routeTransforms: nil, + crdTransforms: []elbv2model.Transform{ + { + Type: elbv2model.TransformTypeHostHeaderRewrite, + HostHeaderRewriteConfig: &elbv2model.RewriteConfigObject{ + Rewrites: []elbv2model.RewriteConfig{ + {Regex: ".*", Replace: "crd.example.com"}, + }, + }, + }, + }, + expected: []elbv2model.Transform{ + { + Type: elbv2model.TransformTypeHostHeaderRewrite, + HostHeaderRewriteConfig: &elbv2model.RewriteConfigObject{ + Rewrites: []elbv2model.RewriteConfig{ + {Regex: ".*", Replace: "crd.example.com"}, + }, + }, + }, + }, + }, + { + name: "crd overrides route transform of same type", + routeTransforms: []elbv2model.Transform{ + { + Type: elbv2model.TransformTypeHostHeaderRewrite, + HostHeaderRewriteConfig: &elbv2model.RewriteConfigObject{ + Rewrites: []elbv2model.RewriteConfig{ + {Regex: ".*", Replace: "route.example.com"}, + }, + }, + }, + }, + crdTransforms: []elbv2model.Transform{ + { + Type: elbv2model.TransformTypeHostHeaderRewrite, + HostHeaderRewriteConfig: &elbv2model.RewriteConfigObject{ + Rewrites: []elbv2model.RewriteConfig{ + {Regex: ".*", Replace: "crd.example.com"}, + }, + }, + }, + }, + expected: []elbv2model.Transform{ + { + Type: elbv2model.TransformTypeHostHeaderRewrite, + HostHeaderRewriteConfig: &elbv2model.RewriteConfigObject{ + Rewrites: []elbv2model.RewriteConfig{ + {Regex: ".*", Replace: "crd.example.com"}, + }, + }, + }, + }, + }, + { + name: "different types are merged together", + routeTransforms: []elbv2model.Transform{ + { + Type: elbv2model.TransformTypeUrlRewrite, + UrlRewriteConfig: &elbv2model.RewriteConfigObject{ + Rewrites: []elbv2model.RewriteConfig{ + {Regex: "^/old/(.*)", Replace: "/new/$1"}, + }, + }, + }, + }, + crdTransforms: []elbv2model.Transform{ + { + Type: elbv2model.TransformTypeHostHeaderRewrite, + HostHeaderRewriteConfig: &elbv2model.RewriteConfigObject{ + Rewrites: []elbv2model.RewriteConfig{ + {Regex: ".*", Replace: "crd.example.com"}, + }, + }, + }, + }, + expected: []elbv2model.Transform{ + { + Type: elbv2model.TransformTypeUrlRewrite, + UrlRewriteConfig: &elbv2model.RewriteConfigObject{ + Rewrites: []elbv2model.RewriteConfig{ + {Regex: "^/old/(.*)", Replace: "/new/$1"}, + }, + }, + }, + { + Type: elbv2model.TransformTypeHostHeaderRewrite, + HostHeaderRewriteConfig: &elbv2model.RewriteConfigObject{ + Rewrites: []elbv2model.RewriteConfig{ + {Regex: ".*", Replace: "crd.example.com"}, + }, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := mergeTransforms(tc.routeTransforms, tc.crdTransforms) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/pkg/gateway/routeutils/route_rule_transform.go b/pkg/gateway/routeutils/route_rule_transform.go index a6f82b49d4..a58a520258 100644 --- a/pkg/gateway/routeutils/route_rule_transform.go +++ b/pkg/gateway/routeutils/route_rule_transform.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + elbv2gw "sigs.k8s.io/aws-load-balancer-controller/apis/gateway/v1beta1" elbv2model "sigs.k8s.io/aws-load-balancer-controller/pkg/model/elbv2" gwv1 "sigs.k8s.io/gateway-api/apis/v1" ) @@ -122,3 +123,49 @@ func generatePrefixReplacementRegex(httpMatch *gwv1.HTTPRouteMatch, replacement return fmt.Sprintf("(^%s(/)?)", match), replacement } + +// BuildListenerRuleConfigTransforms converts transforms from the ListenerRuleConfiguration CRD +// into internal model transforms. These are merged with any transforms generated from +// Gateway API HTTPRoute filters (e.g., URLRewrite). +func BuildListenerRuleConfigTransforms(ruleWithPrecedence RulePrecedence) []elbv2model.Transform { + rule := ruleWithPrecedence.CommonRulePrecedence.Rule + if rule.GetListenerRuleConfig() == nil { + return nil + } + crdTransforms := rule.GetListenerRuleConfig().Spec.Transforms + if len(crdTransforms) == 0 { + return nil + } + var transforms []elbv2model.Transform + for _, crdTransform := range crdTransforms { + transforms = append(transforms, convertCRDTransform(crdTransform)) + } + return transforms +} + +func convertCRDTransform(crdTransform elbv2gw.ListenerRuleTransform) elbv2model.Transform { + switch crdTransform.Type { + case elbv2gw.ListenerRuleTransformTypeHostHeaderRewrite: + return convertCRDHostHeaderRewriteTransform(crdTransform) + default: + return elbv2model.Transform{} + } +} + +func convertCRDHostHeaderRewriteTransform(crdTransform elbv2gw.ListenerRuleTransform) elbv2model.Transform { + rewrites := make([]elbv2model.RewriteConfig, 0, len(crdTransform.HostHeaderRewriteConfig.Rewrites)) + for _, r := range crdTransform.HostHeaderRewriteConfig.Rewrites { + rewrites = append(rewrites, elbv2model.RewriteConfig{ + Regex: r.Regex, + Replace: r.Replace, + }) + } + rewriteObj := &elbv2model.RewriteConfigObject{ + Rewrites: rewrites, + SourceHeader: crdTransform.HostHeaderRewriteConfig.SourceHeader, + } + return elbv2model.Transform{ + Type: elbv2model.TransformTypeHostHeaderRewrite, + HostHeaderRewriteConfig: rewriteObj, + } +} diff --git a/pkg/gateway/routeutils/route_rule_transform_test.go b/pkg/gateway/routeutils/route_rule_transform_test.go index 6c00977bd3..672fcdbad4 100644 --- a/pkg/gateway/routeutils/route_rule_transform_test.go +++ b/pkg/gateway/routeutils/route_rule_transform_test.go @@ -4,6 +4,7 @@ import ( awssdk "github.com/aws/aws-sdk-go-v2/aws" "github.com/stretchr/testify/assert" "regexp" + elbv2gw "sigs.k8s.io/aws-load-balancer-controller/apis/gateway/v1beta1" "sigs.k8s.io/aws-load-balancer-controller/pkg/model/elbv2" gwv1 "sigs.k8s.io/gateway-api/apis/v1" "testing" @@ -489,3 +490,117 @@ func Test_generateHostHeaderRewriteTransform(t *testing.T) { }) } } + +func Test_BuildListenerRuleConfigTransforms(t *testing.T) { + testCases := []struct { + name string + rule RulePrecedence + expected []elbv2.Transform + }{ + { + name: "nil listener rule config", + rule: RulePrecedence{ + CommonRulePrecedence: CommonRulePrecedence{ + Rule: convertHTTPRouteRule(&gwv1.HTTPRouteRule{}, nil, nil), + }, + }, + expected: nil, + }, + { + name: "empty transforms in listener rule config", + rule: RulePrecedence{ + CommonRulePrecedence: CommonRulePrecedence{ + Rule: convertHTTPRouteRule(&gwv1.HTTPRouteRule{}, nil, &elbv2gw.ListenerRuleConfiguration{ + Spec: elbv2gw.ListenerRuleConfigurationSpec{ + Transforms: []elbv2gw.ListenerRuleTransform{}, + }, + }), + }, + }, + expected: nil, + }, + { + name: "host header rewrite transform from CRD", + rule: RulePrecedence{ + CommonRulePrecedence: CommonRulePrecedence{ + Rule: convertHTTPRouteRule(&gwv1.HTTPRouteRule{}, nil, &elbv2gw.ListenerRuleConfiguration{ + Spec: elbv2gw.ListenerRuleConfigurationSpec{ + Transforms: []elbv2gw.ListenerRuleTransform{ + { + Type: elbv2gw.ListenerRuleTransformTypeHostHeaderRewrite, + HostHeaderRewriteConfig: &elbv2gw.ListenerRuleHostHeaderRewriteConfig{ + Rewrites: []elbv2gw.ListenerRuleRewriteConfig{ + { + Regex: ".*", + Replace: "tenant.example.com", + }, + }, + }, + }, + }, + }, + }), + }, + }, + expected: []elbv2.Transform{ + { + Type: elbv2.TransformTypeHostHeaderRewrite, + HostHeaderRewriteConfig: &elbv2.RewriteConfigObject{ + Rewrites: []elbv2.RewriteConfig{ + { + Regex: ".*", + Replace: "tenant.example.com", + }, + }, + }, + }, + }, + }, + { + name: "host header rewrite with source header from CRD", + rule: RulePrecedence{ + CommonRulePrecedence: CommonRulePrecedence{ + Rule: convertHTTPRouteRule(&gwv1.HTTPRouteRule{}, nil, &elbv2gw.ListenerRuleConfiguration{ + Spec: elbv2gw.ListenerRuleConfigurationSpec{ + Transforms: []elbv2gw.ListenerRuleTransform{ + { + Type: elbv2gw.ListenerRuleTransformTypeHostHeaderRewrite, + HostHeaderRewriteConfig: &elbv2gw.ListenerRuleHostHeaderRewriteConfig{ + Rewrites: []elbv2gw.ListenerRuleRewriteConfig{ + { + Regex: ".*", + Replace: "$0", + }, + }, + SourceHeader: awssdk.String("X-School-Domain"), + }, + }, + }, + }, + }), + }, + }, + expected: []elbv2.Transform{ + { + Type: elbv2.TransformTypeHostHeaderRewrite, + HostHeaderRewriteConfig: &elbv2.RewriteConfigObject{ + Rewrites: []elbv2.RewriteConfig{ + { + Regex: ".*", + Replace: "$0", + }, + }, + SourceHeader: awssdk.String("X-School-Domain"), + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := BuildListenerRuleConfigTransforms(tc.rule) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/pkg/model/elbv2/listener_rule.go b/pkg/model/elbv2/listener_rule.go index 10923952f8..f9001bc6f5 100644 --- a/pkg/model/elbv2/listener_rule.go +++ b/pkg/model/elbv2/listener_rule.go @@ -154,6 +154,11 @@ type RewriteConfig struct { type RewriteConfigObject struct { // Rewrites for the transform Rewrites []RewriteConfig `json:"rewrites"` + // SourceHeader specifies a request header whose value is used as input + // for the host header rewrite. When set, the ALB will use this header's + // value (instead of the current Host header) as the source for regex matching. + // +optional + SourceHeader *string `json:"sourceHeader,omitempty"` } type Transform struct {