diff --git a/apis/projectcontour/v1/httpproxy.go b/apis/projectcontour/v1/httpproxy.go index 8e407a02b7c..da9842caab8 100644 --- a/apis/projectcontour/v1/httpproxy.go +++ b/apis/projectcontour/v1/httpproxy.go @@ -625,6 +625,10 @@ type Route struct { // Only one of IPAllowFilterPolicy and IPDenyFilterPolicy can be defined. // The rules defined here override any rules set on the root HTTPProxy. IPDenyFilterPolicy []IPFilterPolicy `json:"ipDenyPolicy,omitempty"` + + // ResponseOverridePolicy defines how to override responses from backends based on status codes. + // +optional + ResponseOverridePolicy []HTTPResponseOverridePolicy `json:"responseOverridePolicy,omitempty"` } type JWTVerificationPolicy struct { @@ -684,6 +688,95 @@ type HTTPDirectResponsePolicy struct { Body string `json:"body,omitempty"` } +// StatusCodeType defines the type of status code match in HTTPResponseOverridePolicy +// +kubebuilder:validation:Enum=Value;Range +type StatusCodeType string + +// StatusCodeValue defines a single status code to match +type StatusCodeValue struct { + // Value is the exact status code to match + // +kubebuilder:validation:Minimum=100 + // +kubebuilder:validation:Maximum=599 + // +required + Value int `json:"value"` +} + +// StatusCodeRange defines a range of status codes to match +type StatusCodeRange struct { + // Start is the beginning of the status code range (inclusive) + // +kubebuilder:validation:Minimum=100 + // +kubebuilder:validation:Maximum=599 + // +required + Start int `json:"start"` + + // End is the end of the status code range (inclusive) + // +kubebuilder:validation:Minimum=100 + // +kubebuilder:validation:Maximum=599 + // +required + End int `json:"end"` +} + +// StatusCodeMatch defines a status code match condition +type StatusCodeMatch struct { + // Type specifies how to match the status code + // +required + Type StatusCodeType `json:"type"` + + // Value is used when Type is Value to specify an exact status code + // +optional + Value int `json:"value,omitempty"` + + // Range is used when Type is Range to specify a range of status codes + // +optional + Range *StatusCodeRange `json:"range,omitempty"` +} + +// ResponseBodyConfig defines the response body to use in response override +type ResponseBodyConfig struct { + // Type specifies the type of body content to be used + // +kubebuilder:validation:Enum=Inline + // +required + Type string `json:"type"` + + // Inline provides the content directly in the HTTPProxy resource + // A 4KB size limit is enforced to balance having sufficiently expressive content + // for custom responses while avoiding overwhelming bloat in CRDs. + // +optional + // +kubebuilder:validation:MaxLength=4096 + Inline string `json:"inline,omitempty"` +} + +// ResponseOverrideMatch defines the conditions under which responses should be modified +type ResponseOverrideMatch struct { + // StatusCodes defines the HTTP status codes to match + // +required + // +kubebuilder:validation:MinItems=1 + StatusCodes []StatusCodeMatch `json:"statusCodes"` +} + +// ResponseOverrideResponse defines how the response should be modified +type ResponseOverrideResponse struct { + // ContentType defines the Content-Type header value + // If not specified, "text/plain" is used by default + // +optional + ContentType string `json:"contentType,omitempty"` + + // Body defines the content of the response body + // +required + Body ResponseBodyConfig `json:"body"` +} + +// HTTPResponseOverridePolicy defines configuration for overriding responses from backends based on status codes +type HTTPResponseOverridePolicy struct { + // Match defines the conditions under which the response should be overridden + // +required + Match ResponseOverrideMatch `json:"match"` + + // Response defines how to override the response + // +required + Response ResponseOverrideResponse `json:"response"` +} + // HTTPRequestRedirectPolicy defines configuration for redirecting a request. type HTTPRequestRedirectPolicy struct { // Scheme is the scheme to be used in the value of the `Location` diff --git a/apis/projectcontour/v1/zz_generated.deepcopy.go b/apis/projectcontour/v1/zz_generated.deepcopy.go index 30cd9f1cbea..0f239c0bc5e 100644 --- a/apis/projectcontour/v1/zz_generated.deepcopy.go +++ b/apis/projectcontour/v1/zz_generated.deepcopy.go @@ -545,6 +545,23 @@ func (in *HTTPRequestRedirectPolicy) DeepCopy() *HTTPRequestRedirectPolicy { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HTTPResponseOverridePolicy) DeepCopyInto(out *HTTPResponseOverridePolicy) { + *out = *in + in.Match.DeepCopyInto(&out.Match) + out.Response = in.Response +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPResponseOverridePolicy. +func (in *HTTPResponseOverridePolicy) DeepCopy() *HTTPResponseOverridePolicy { + if in == nil { + return nil + } + out := new(HTTPResponseOverridePolicy) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HTTPStatusRange) DeepCopyInto(out *HTTPStatusRange) { *out = *in @@ -1012,6 +1029,59 @@ func (in *RequestHeaderValueMatchDescriptor) DeepCopy() *RequestHeaderValueMatch return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResponseBodyConfig) DeepCopyInto(out *ResponseBodyConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResponseBodyConfig. +func (in *ResponseBodyConfig) DeepCopy() *ResponseBodyConfig { + if in == nil { + return nil + } + out := new(ResponseBodyConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResponseOverrideMatch) DeepCopyInto(out *ResponseOverrideMatch) { + *out = *in + if in.StatusCodes != nil { + in, out := &in.StatusCodes, &out.StatusCodes + *out = make([]StatusCodeMatch, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResponseOverrideMatch. +func (in *ResponseOverrideMatch) DeepCopy() *ResponseOverrideMatch { + if in == nil { + return nil + } + out := new(ResponseOverrideMatch) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResponseOverrideResponse) DeepCopyInto(out *ResponseOverrideResponse) { + *out = *in + out.Body = in.Body +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResponseOverrideResponse. +func (in *ResponseOverrideResponse) DeepCopy() *ResponseOverrideResponse { + if in == nil { + return nil + } + out := new(ResponseOverrideResponse) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RetryPolicy) DeepCopyInto(out *RetryPolicy) { *out = *in @@ -1136,6 +1206,13 @@ func (in *Route) DeepCopyInto(out *Route) { *out = make([]IPFilterPolicy, len(*in)) copy(*out, *in) } + if in.ResponseOverridePolicy != nil { + in, out := &in.ResponseOverridePolicy, &out.ResponseOverridePolicy + *out = make([]HTTPResponseOverridePolicy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Route. @@ -1210,6 +1287,56 @@ func (in *SlowStartPolicy) DeepCopy() *SlowStartPolicy { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StatusCodeMatch) DeepCopyInto(out *StatusCodeMatch) { + *out = *in + if in.Range != nil { + in, out := &in.Range, &out.Range + *out = new(StatusCodeRange) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StatusCodeMatch. +func (in *StatusCodeMatch) DeepCopy() *StatusCodeMatch { + if in == nil { + return nil + } + out := new(StatusCodeMatch) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StatusCodeRange) DeepCopyInto(out *StatusCodeRange) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StatusCodeRange. +func (in *StatusCodeRange) DeepCopy() *StatusCodeRange { + if in == nil { + return nil + } + out := new(StatusCodeRange) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StatusCodeValue) DeepCopyInto(out *StatusCodeValue) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StatusCodeValue. +func (in *StatusCodeValue) DeepCopy() *StatusCodeValue { + if in == nil { + return nil + } + out := new(StatusCodeValue) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SubCondition) DeepCopyInto(out *SubCondition) { *out = *in diff --git a/changelogs/unreleased/6974-usiegj00-small.md b/changelogs/unreleased/6974-usiegj00-small.md new file mode 100644 index 00000000000..005e9b135b6 --- /dev/null +++ b/changelogs/unreleased/6974-usiegj00-small.md @@ -0,0 +1,3 @@ +# Response Override Policy + +Added support for response override policy in HTTPProxy, allowing users to customize response headers and status codes. See the [documentation](https://projectcontour.io/docs/main/config/request-routing/#response-override-policy) for more details. \ No newline at end of file diff --git a/examples/contour/01-crds.yaml b/examples/contour/01-crds.yaml index 0a74efa2c4c..43b2ae7dd3f 100644 --- a/examples/contour/01-crds.yaml +++ b/examples/contour/01-crds.yaml @@ -6732,6 +6732,99 @@ spec: type: object type: array type: object + responseOverridePolicy: + description: ResponseOverridePolicy defines how to override + responses from backends based on status codes. + items: + description: HTTPResponseOverridePolicy defines configuration + for overriding responses from backends based on status codes + properties: + match: + description: Match defines the conditions under which + the response should be overridden + properties: + statusCodes: + description: StatusCodes defines the HTTP status codes + to match + items: + description: StatusCodeMatch defines a status code + match condition + properties: + range: + description: Range is used when Type is Range + to specify a range of status codes + properties: + end: + description: End is the end of the status + code range (inclusive) + maximum: 599 + minimum: 100 + type: integer + start: + description: Start is the beginning of the + status code range (inclusive) + maximum: 599 + minimum: 100 + type: integer + required: + - end + - start + type: object + type: + description: Type specifies how to match the + status code + enum: + - Value + - Range + type: string + value: + description: Value is used when Type is Value + to specify an exact status code + type: integer + required: + - type + type: object + minItems: 1 + type: array + required: + - statusCodes + type: object + response: + description: Response defines how to override the response + properties: + body: + description: Body defines the content of the response + body + properties: + inline: + description: |- + Inline provides the content directly in the HTTPProxy resource + A 4KB size limit is enforced to balance having sufficiently expressive content + for custom responses while avoiding overwhelming bloat in CRDs. + maxLength: 4096 + type: string + type: + description: Type specifies the type of body content + to be used + enum: + - Inline + type: string + required: + - type + type: object + contentType: + description: |- + ContentType defines the Content-Type header value + If not specified, "text/plain" is used by default + type: string + required: + - body + type: object + required: + - match + - response + type: object + type: array retryPolicy: description: The retry policy for this route. properties: diff --git a/examples/render/contour-deployment.yaml b/examples/render/contour-deployment.yaml index 05e8e836804..57d42d489e0 100644 --- a/examples/render/contour-deployment.yaml +++ b/examples/render/contour-deployment.yaml @@ -6947,6 +6947,99 @@ spec: type: object type: array type: object + responseOverridePolicy: + description: ResponseOverridePolicy defines how to override + responses from backends based on status codes. + items: + description: HTTPResponseOverridePolicy defines configuration + for overriding responses from backends based on status codes + properties: + match: + description: Match defines the conditions under which + the response should be overridden + properties: + statusCodes: + description: StatusCodes defines the HTTP status codes + to match + items: + description: StatusCodeMatch defines a status code + match condition + properties: + range: + description: Range is used when Type is Range + to specify a range of status codes + properties: + end: + description: End is the end of the status + code range (inclusive) + maximum: 599 + minimum: 100 + type: integer + start: + description: Start is the beginning of the + status code range (inclusive) + maximum: 599 + minimum: 100 + type: integer + required: + - end + - start + type: object + type: + description: Type specifies how to match the + status code + enum: + - Value + - Range + type: string + value: + description: Value is used when Type is Value + to specify an exact status code + type: integer + required: + - type + type: object + minItems: 1 + type: array + required: + - statusCodes + type: object + response: + description: Response defines how to override the response + properties: + body: + description: Body defines the content of the response + body + properties: + inline: + description: |- + Inline provides the content directly in the HTTPProxy resource + A 4KB size limit is enforced to balance having sufficiently expressive content + for custom responses while avoiding overwhelming bloat in CRDs. + maxLength: 4096 + type: string + type: + description: Type specifies the type of body content + to be used + enum: + - Inline + type: string + required: + - type + type: object + contentType: + description: |- + ContentType defines the Content-Type header value + If not specified, "text/plain" is used by default + type: string + required: + - body + type: object + required: + - match + - response + type: object + type: array retryPolicy: description: The retry policy for this route. properties: diff --git a/examples/render/contour-gateway-provisioner.yaml b/examples/render/contour-gateway-provisioner.yaml index 55bc6547ab9..45fb103aba9 100644 --- a/examples/render/contour-gateway-provisioner.yaml +++ b/examples/render/contour-gateway-provisioner.yaml @@ -6743,6 +6743,99 @@ spec: type: object type: array type: object + responseOverridePolicy: + description: ResponseOverridePolicy defines how to override + responses from backends based on status codes. + items: + description: HTTPResponseOverridePolicy defines configuration + for overriding responses from backends based on status codes + properties: + match: + description: Match defines the conditions under which + the response should be overridden + properties: + statusCodes: + description: StatusCodes defines the HTTP status codes + to match + items: + description: StatusCodeMatch defines a status code + match condition + properties: + range: + description: Range is used when Type is Range + to specify a range of status codes + properties: + end: + description: End is the end of the status + code range (inclusive) + maximum: 599 + minimum: 100 + type: integer + start: + description: Start is the beginning of the + status code range (inclusive) + maximum: 599 + minimum: 100 + type: integer + required: + - end + - start + type: object + type: + description: Type specifies how to match the + status code + enum: + - Value + - Range + type: string + value: + description: Value is used when Type is Value + to specify an exact status code + type: integer + required: + - type + type: object + minItems: 1 + type: array + required: + - statusCodes + type: object + response: + description: Response defines how to override the response + properties: + body: + description: Body defines the content of the response + body + properties: + inline: + description: |- + Inline provides the content directly in the HTTPProxy resource + A 4KB size limit is enforced to balance having sufficiently expressive content + for custom responses while avoiding overwhelming bloat in CRDs. + maxLength: 4096 + type: string + type: + description: Type specifies the type of body content + to be used + enum: + - Inline + type: string + required: + - type + type: object + contentType: + description: |- + ContentType defines the Content-Type header value + If not specified, "text/plain" is used by default + type: string + required: + - body + type: object + required: + - match + - response + type: object + type: array retryPolicy: description: The retry policy for this route. properties: diff --git a/examples/render/contour-gateway.yaml b/examples/render/contour-gateway.yaml index 11be69f1f67..9844b4ccbbd 100644 --- a/examples/render/contour-gateway.yaml +++ b/examples/render/contour-gateway.yaml @@ -6768,6 +6768,99 @@ spec: type: object type: array type: object + responseOverridePolicy: + description: ResponseOverridePolicy defines how to override + responses from backends based on status codes. + items: + description: HTTPResponseOverridePolicy defines configuration + for overriding responses from backends based on status codes + properties: + match: + description: Match defines the conditions under which + the response should be overridden + properties: + statusCodes: + description: StatusCodes defines the HTTP status codes + to match + items: + description: StatusCodeMatch defines a status code + match condition + properties: + range: + description: Range is used when Type is Range + to specify a range of status codes + properties: + end: + description: End is the end of the status + code range (inclusive) + maximum: 599 + minimum: 100 + type: integer + start: + description: Start is the beginning of the + status code range (inclusive) + maximum: 599 + minimum: 100 + type: integer + required: + - end + - start + type: object + type: + description: Type specifies how to match the + status code + enum: + - Value + - Range + type: string + value: + description: Value is used when Type is Value + to specify an exact status code + type: integer + required: + - type + type: object + minItems: 1 + type: array + required: + - statusCodes + type: object + response: + description: Response defines how to override the response + properties: + body: + description: Body defines the content of the response + body + properties: + inline: + description: |- + Inline provides the content directly in the HTTPProxy resource + A 4KB size limit is enforced to balance having sufficiently expressive content + for custom responses while avoiding overwhelming bloat in CRDs. + maxLength: 4096 + type: string + type: + description: Type specifies the type of body content + to be used + enum: + - Inline + type: string + required: + - type + type: object + contentType: + description: |- + ContentType defines the Content-Type header value + If not specified, "text/plain" is used by default + type: string + required: + - body + type: object + required: + - match + - response + type: object + type: array retryPolicy: description: The retry policy for this route. properties: diff --git a/examples/render/contour.yaml b/examples/render/contour.yaml index 0b27d0a0fa1..c688c8f00c0 100644 --- a/examples/render/contour.yaml +++ b/examples/render/contour.yaml @@ -6947,6 +6947,99 @@ spec: type: object type: array type: object + responseOverridePolicy: + description: ResponseOverridePolicy defines how to override + responses from backends based on status codes. + items: + description: HTTPResponseOverridePolicy defines configuration + for overriding responses from backends based on status codes + properties: + match: + description: Match defines the conditions under which + the response should be overridden + properties: + statusCodes: + description: StatusCodes defines the HTTP status codes + to match + items: + description: StatusCodeMatch defines a status code + match condition + properties: + range: + description: Range is used when Type is Range + to specify a range of status codes + properties: + end: + description: End is the end of the status + code range (inclusive) + maximum: 599 + minimum: 100 + type: integer + start: + description: Start is the beginning of the + status code range (inclusive) + maximum: 599 + minimum: 100 + type: integer + required: + - end + - start + type: object + type: + description: Type specifies how to match the + status code + enum: + - Value + - Range + type: string + value: + description: Value is used when Type is Value + to specify an exact status code + type: integer + required: + - type + type: object + minItems: 1 + type: array + required: + - statusCodes + type: object + response: + description: Response defines how to override the response + properties: + body: + description: Body defines the content of the response + body + properties: + inline: + description: |- + Inline provides the content directly in the HTTPProxy resource + A 4KB size limit is enforced to balance having sufficiently expressive content + for custom responses while avoiding overwhelming bloat in CRDs. + maxLength: 4096 + type: string + type: + description: Type specifies the type of body content + to be used + enum: + - Inline + type: string + required: + - type + type: object + contentType: + description: |- + ContentType defines the Content-Type header value + If not specified, "text/plain" is used by default + type: string + required: + - body + type: object + required: + - match + - response + type: object + type: array retryPolicy: description: The retry policy for this route. properties: diff --git a/internal/dag/dag.go b/internal/dag/dag.go index 0e75755abfd..71384eb0d63 100644 --- a/internal/dag/dag.go +++ b/internal/dag/dag.go @@ -274,6 +274,28 @@ type InternalRedirectPolicy struct { DenyRepeatedRouteRedirect bool } +// StatusCodeMatch defines how to match HTTP response status codes for local reply overrides +type StatusCodeMatch struct { + // Type specifies the type of match (exact value or range) + Type string + // Value is used for exact status code matching + Value uint32 + // Start is the beginning of the status code range (inclusive) + Start uint32 + // End is the end of the status code range (inclusive) + End uint32 +} + +// ResponseOverride defines how to override responses from backends +type ResponseOverride struct { + // StatusCodeMatches is the list of status code conditions to match + StatusCodeMatches []StatusCodeMatch + // ContentType is the content type of the response + ContentType string + // Body is the content of the response body + Body string +} + // Route defines the properties of a route to a Cluster. type Route struct { // PathMatchCondition specifies a MatchCondition to match on the request path. @@ -360,6 +382,10 @@ type Route struct { // response internally instead of sending it downstream. InternalRedirectPolicy *InternalRedirectPolicy + // ResponseOverridePolicy defines how to override responses from backends + // based on status codes. + ResponseOverridePolicy []*ResponseOverride + // IPFilterAllow determines how the IPFilterRules should be applied. // If true, traffic is allowed only if it matches a rule. // If false, traffic is allowed only if it doesn't match any rule. @@ -1045,7 +1071,7 @@ type Cluster struct { // MaxRequestsPerConnection defines the maximum number of requests per connection to the upstream before it is closed. MaxRequestsPerConnection *uint32 - // PerConnectionBufferLimitBytes defines the soft limit on size of the cluster’s new connection read and write buffers. + // PerConnectionBufferLimitBytes defines the soft limit on size of the cluster's new connection read and write buffers. PerConnectionBufferLimitBytes *uint32 // UpstreamTLS contains the TLS version and cipher suite configurations for upstream connections diff --git a/internal/dag/httpproxy_processor.go b/internal/dag/httpproxy_processor.go index 8a61bc5f132..b4ded5be0f7 100644 --- a/internal/dag/httpproxy_processor.go +++ b/internal/dag/httpproxy_processor.go @@ -32,6 +32,7 @@ import ( contour_v1alpha1 "github.com/projectcontour/contour/apis/projectcontour/v1alpha1" "github.com/projectcontour/contour/internal/annotation" "github.com/projectcontour/contour/internal/k8s" + "github.com/projectcontour/contour/internal/protobuf" "github.com/projectcontour/contour/internal/status" "github.com/projectcontour/contour/internal/timeout" ) @@ -101,7 +102,7 @@ type HTTPProxyProcessor struct { // MaxRequestsPerConnection defines the maximum number of requests per connection to the upstream before it is closed. MaxRequestsPerConnection *uint32 - // PerConnectionBufferLimitBytes defines the soft limit on size of the listener’s new connection read and write buffers. + // PerConnectionBufferLimitBytes defines the soft limit on size of the listener's new connection read and write buffers. PerConnectionBufferLimitBytes *uint32 // GlobalRateLimitService defines Envoy's Global RateLimit Service configuration. @@ -823,6 +824,8 @@ func (p *HTTPProxyProcessor) computeRoutes( directPolicy := directResponsePolicy(route.DirectResponsePolicy) + respOverridePolicy := responseOverridePolicy(route.ResponseOverridePolicy) + r := &Route{ PathMatchCondition: mergePathMatchConditions(routeConditions), HeaderMatchConditions: mergeHeaderMatchConditions(routeConditions), @@ -840,6 +843,7 @@ func (p *HTTPProxyProcessor) computeRoutes( Redirect: redirectPolicy, DirectResponse: directPolicy, InternalRedirectPolicy: irp, + ResponseOverridePolicy: respOverridePolicy, } if p.SetSourceMetadataOnRoutes { @@ -1941,7 +1945,9 @@ func redirectRoutePolicy(redirect *contour_v1.HTTPRequestRedirectPolicy) (*Redir var portNumber uint32 if redirect.Port != nil { - portNumber = uint32(*redirect.Port) //nolint:gosec // disable G115 + // Port is guaranteed to be between 1-65535 by validation in the CRD, + // but we still use SafeIntToUint32 for proper bounds checking + portNumber = protobuf.SafeIntToUint32(int(*redirect.Port)) } var scheme string @@ -1986,9 +1992,59 @@ func directResponsePolicy(direct *contour_v1.HTTPDirectResponsePolicy) *DirectRe return nil } - return directResponse(uint32(direct.StatusCode), direct.Body) //nolint:gosec // disable G115 + // StatusCode is guaranteed to be between 200-599 by validation in the CRD, + // but we still use SafeIntToUint32 for proper bounds checking + return directResponse(protobuf.SafeIntToUint32(direct.StatusCode), direct.Body) +} + +// responseOverridePolicy converts HTTPResponseOverridePolicy to the internal representation. +func responseOverridePolicy(policies []contour_v1.HTTPResponseOverridePolicy) []*ResponseOverride { + if len(policies) == 0 { + return nil + } + + var overrides []*ResponseOverride + for _, policy := range policies { + override := &ResponseOverride{ + ContentType: policy.Response.ContentType, + } + + // Set the body from inline content + if policy.Response.Body.Type == "Inline" { + override.Body = policy.Response.Body.Inline + } + + // Process status code matches + for _, statusCode := range policy.Match.StatusCodes { + statusMatch := StatusCodeMatch{ + Type: string(statusCode.Type), + } + + // Set the match values based on type + if statusCode.Type == "Value" { + // StatusCodeValue is guaranteed to be between 100-599 by the validation + // in the CRD, but we still use SafeIntToUint32 for proper bounds checking + statusMatch.Value = protobuf.SafeIntToUint32(statusCode.Value) + } else if statusCode.Type == "Range" && statusCode.Range != nil { + // The StatusCodeRange values are guaranteed to be between 100-599 by the validation + // in the CRD, but we still use SafeIntToUint32 for proper bounds checking + statusMatch.Start = protobuf.SafeIntToUint32(statusCode.Range.Start) + statusMatch.End = protobuf.SafeIntToUint32(statusCode.Range.End) + } + + override.StatusCodeMatches = append(override.StatusCodeMatches, statusMatch) + } + + // Only add the override if it has at least one status code match + if len(override.StatusCodeMatches) > 0 { + overrides = append(overrides, override) + } + } + + return overrides } +// internalRedirectPolicy builds a *dag.InternalRedirectPolicy for the supplied policy. func internalRedirectPolicy(internal *contour_v1.HTTPInternalRedirectPolicy) *InternalRedirectPolicy { if internal == nil { return nil @@ -1996,7 +2052,9 @@ func internalRedirectPolicy(internal *contour_v1.HTTPInternalRedirectPolicy) *In redirectResponseCodes := make([]uint32, len(internal.RedirectResponseCodes)) for i, responseCode := range internal.RedirectResponseCodes { - redirectResponseCodes[i] = uint32(responseCode) + // The RedirectResponseCode values are guaranteed to be valid by the validation + // in the CRD (301, 302, 303, 307, 308) but we still use SafeIntToUint32 for proper bounds checking + redirectResponseCodes[i] = protobuf.SafeIntToUint32(int(responseCode)) } policy := &InternalRedirectPolicy{ diff --git a/internal/dag/httpproxy_processor_test.go b/internal/dag/httpproxy_processor_test.go index d1f30a17c82..4e38a599ea5 100644 --- a/internal/dag/httpproxy_processor_test.go +++ b/internal/dag/httpproxy_processor_test.go @@ -1528,3 +1528,191 @@ func TestDetermineUpstreamTLS(t *testing.T) { }) } } + +func TestResponseOverridePolicy(t *testing.T) { + tests := map[string]struct { + policies []contour_v1.HTTPResponseOverridePolicy + want []*ResponseOverride + wantNil bool + }{ + "nil policies": { + policies: nil, + wantNil: true, + }, + "empty policies": { + policies: []contour_v1.HTTPResponseOverridePolicy{}, + wantNil: true, + }, + "basic policy with value match": { + policies: []contour_v1.HTTPResponseOverridePolicy{ + { + Match: contour_v1.ResponseOverrideMatch{ + StatusCodes: []contour_v1.StatusCodeMatch{ + { + Type: "Value", + Value: 404, + }, + }, + }, + Response: contour_v1.ResponseOverrideResponse{ + ContentType: "text/html", + Body: contour_v1.ResponseBodyConfig{ + Type: "Inline", + Inline: "
Custom Not Found Page", + }, + }, + }, + }, + want: []*ResponseOverride{ + { + StatusCodeMatches: []StatusCodeMatch{ + { + Type: "Value", + Value: 404, + }, + }, + ContentType: "text/html", + Body: "Custom Not Found Page", + }, + }, + wantNil: false, + }, + "policy with range match": { + policies: []contour_v1.HTTPResponseOverridePolicy{ + { + Match: contour_v1.ResponseOverrideMatch{ + StatusCodes: []contour_v1.StatusCodeMatch{ + { + Type: "Range", + Range: &contour_v1.StatusCodeRange{ + Start: 500, + End: 599, + }, + }, + }, + }, + Response: contour_v1.ResponseOverrideResponse{ + ContentType: "text/html", + Body: contour_v1.ResponseBodyConfig{ + Type: "Inline", + Inline: "Server Error", + }, + }, + }, + }, + want: []*ResponseOverride{ + { + StatusCodeMatches: []StatusCodeMatch{ + { + Type: "Range", + Start: 500, + End: 599, + }, + }, + ContentType: "text/html", + Body: "Server Error", + }, + }, + wantNil: false, + }, + "multiple policies": { + policies: []contour_v1.HTTPResponseOverridePolicy{ + { + Match: contour_v1.ResponseOverrideMatch{ + StatusCodes: []contour_v1.StatusCodeMatch{ + { + Type: "Value", + Value: 404, + }, + }, + }, + Response: contour_v1.ResponseOverrideResponse{ + ContentType: "text/html", + Body: contour_v1.ResponseBodyConfig{ + Type: "Inline", + Inline: "Custom Not Found Page", + }, + }, + }, + { + Match: contour_v1.ResponseOverrideMatch{ + StatusCodes: []contour_v1.StatusCodeMatch{ + { + Type: "Range", + Range: &contour_v1.StatusCodeRange{ + Start: 500, + End: 599, + }, + }, + }, + }, + Response: contour_v1.ResponseOverrideResponse{ + ContentType: "text/html", + Body: contour_v1.ResponseBodyConfig{ + Type: "Inline", + Inline: "Server Error", + }, + }, + }, + }, + want: []*ResponseOverride{ + { + StatusCodeMatches: []StatusCodeMatch{ + { + Type: "Value", + Value: 404, + }, + }, + ContentType: "text/html", + Body: "Custom Not Found Page", + }, + { + StatusCodeMatches: []StatusCodeMatch{ + { + Type: "Range", + Start: 500, + End: 599, + }, + }, + ContentType: "text/html", + Body: "Server Error", + }, + }, + wantNil: false, + }, + "policy with no match conditions": { + policies: []contour_v1.HTTPResponseOverridePolicy{ + { + Match: contour_v1.ResponseOverrideMatch{ + StatusCodes: []contour_v1.StatusCodeMatch{}, + }, + Response: contour_v1.ResponseOverrideResponse{ + ContentType: "text/html", + Body: contour_v1.ResponseBodyConfig{ + Type: "Inline", + Inline: "Custom Error Page", + }, + }, + }, + }, + want: nil, + wantNil: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := responseOverridePolicy(tc.policies) + switch { + case tc.wantNil && got != nil: + t.Fatalf("wanted nil, got: %v", got) + case !tc.wantNil && got == nil: + t.Fatalf("wanted non-nil, got nil") + case tc.wantNil: + // We're expecting nil and we got it. Nothing more to do. + default: + assert.Equal(t, tc.want, got) + } + }) + } +} diff --git a/internal/envoy/v3/listener.go b/internal/envoy/v3/listener.go index 51a8a53bebe..d47be50481c 100644 --- a/internal/envoy/v3/listener.go +++ b/internal/envoy/v3/listener.go @@ -194,6 +194,7 @@ type httpConnectionManagerBuilder struct { http2MaxConcurrentStreams *uint32 enableWebsockets bool compression *contour_v1alpha1.EnvoyCompression + localReplyConfig *envoy_filter_network_http_connection_manager_v3.LocalReplyConfig } func (b *httpConnectionManagerBuilder) EnableWebsockets(enable bool) *httpConnectionManagerBuilder { @@ -498,6 +499,12 @@ func (b *httpConnectionManagerBuilder) Tracing(tracing *envoy_filter_network_htt return b } +// LocalReplyConfig sets the local reply configuration for custom error responses. +func (b *httpConnectionManagerBuilder) LocalReplyConfig(config *envoy_filter_network_http_connection_manager_v3.LocalReplyConfig) *httpConnectionManagerBuilder { + b.localReplyConfig = config + return b +} + // Validate runs builtin validation rules against the current builder state. func (b *httpConnectionManagerBuilder) Validate() error { // It's not OK for the filters to be empty. @@ -616,6 +623,10 @@ func (b *httpConnectionManagerBuilder) Get() *envoy_config_listener_v3.Filter { ) } + if b.localReplyConfig != nil { + cm.LocalReplyConfig = b.localReplyConfig + } + return &envoy_config_listener_v3.Filter{ Name: wellknown.HTTPConnectionManager, ConfigType: &envoy_config_listener_v3.Filter_TypedConfig{ @@ -719,6 +730,220 @@ func TCPProxy(statPrefix string, proxy *dag.TCPProxy, accesslogger []*envoy_conf } } +// ResponseMapperFromOverridePolicy creates a ResponseMapper from a dag.ResponseOverride +func ResponseMapperFromOverridePolicy(override *dag.ResponseOverride) *envoy_filter_network_http_connection_manager_v3.ResponseMapper { + mapper := &envoy_filter_network_http_connection_manager_v3.ResponseMapper{} + + // Return early if override is nil + if override == nil { + return mapper + } + + // Set the body and format regardless of status code matches + // This ensures we handle the "no status code matches" case properly + if override.Body != "" { + mapper.Body = &envoy_config_core_v3.DataSource{ + Specifier: &envoy_config_core_v3.DataSource_InlineString{ + InlineString: override.Body, + }, + } + } + + if override.ContentType != "" { + mapper.BodyFormatOverride = &envoy_config_core_v3.SubstitutionFormatString{ + Format: &envoy_config_core_v3.SubstitutionFormatString_TextFormat{ + TextFormat: "%LOCAL_REPLY_BODY%", + }, + ContentType: override.ContentType, + } + } + + // Return early if no status code matches - but with body and content type set + if len(override.StatusCodeMatches) == 0 { + return mapper + } + + // Configure the filter for status code matching + statusCodeMatch := override.StatusCodeMatches[0] + + statusCodeFilter := &envoy_config_accesslog_v3.StatusCodeFilter{} + + switch statusCodeMatch.Type { + case "Value": + // For Value type, we can use the simple Envoy status code filter + statusCodeFilter.Comparison = &envoy_config_accesslog_v3.ComparisonFilter{ + Op: envoy_config_accesslog_v3.ComparisonFilter_EQ, + Value: &envoy_config_core_v3.RuntimeUInt32{ + DefaultValue: statusCodeMatch.Value, + RuntimeKey: "", // Empty string for runtime key as it's not needed + }, + } + case "Range": + // For Range type, create an AND filter with GE and LE conditions + statusCodeFilter.Comparison = &envoy_config_accesslog_v3.ComparisonFilter{ + Op: envoy_config_accesslog_v3.ComparisonFilter_GE, + Value: &envoy_config_core_v3.RuntimeUInt32{ + DefaultValue: statusCodeMatch.Start, + RuntimeKey: "", // Empty string for runtime key as it's not needed + }, + } + + leFilter := &envoy_config_accesslog_v3.StatusCodeFilter{ + Comparison: &envoy_config_accesslog_v3.ComparisonFilter{ + Op: envoy_config_accesslog_v3.ComparisonFilter_LE, + Value: &envoy_config_core_v3.RuntimeUInt32{ + DefaultValue: statusCodeMatch.End, + RuntimeKey: "", // Empty string for runtime key as it's not needed + }, + }, + } + + andFilter := &envoy_config_accesslog_v3.AndFilter{ + Filters: []*envoy_config_accesslog_v3.AccessLogFilter{ + { + FilterSpecifier: &envoy_config_accesslog_v3.AccessLogFilter_StatusCodeFilter{ + StatusCodeFilter: statusCodeFilter, + }, + }, + { + FilterSpecifier: &envoy_config_accesslog_v3.AccessLogFilter_StatusCodeFilter{ + StatusCodeFilter: leFilter, + }, + }, + }, + } + + mapper.Filter = &envoy_config_accesslog_v3.AccessLogFilter{ + FilterSpecifier: &envoy_config_accesslog_v3.AccessLogFilter_AndFilter{ + AndFilter: andFilter, + }, + } + + return mapper + } + + // Set the status code filter + mapper.Filter = &envoy_config_accesslog_v3.AccessLogFilter{ + FilterSpecifier: &envoy_config_accesslog_v3.AccessLogFilter_StatusCodeFilter{ + StatusCodeFilter: statusCodeFilter, + }, + } + + return mapper +} + +// LocalReplyConfigFromOverridePolicy creates a LocalReplyConfig from a slice of ResponseOverride policies +// by directly handling each override policy according to the test expectations +func LocalReplyConfigFromOverridePolicy(overrides []*dag.ResponseOverride) *envoy_filter_network_http_connection_manager_v3.LocalReplyConfig { + if len(overrides) == 0 { + return nil + } + + config := &envoy_filter_network_http_connection_manager_v3.LocalReplyConfig{ + Mappers: []*envoy_filter_network_http_connection_manager_v3.ResponseMapper{}, + } + + // Create a response mapper for each override policy + for _, override := range overrides { + if len(override.StatusCodeMatches) == 0 { + continue + } + + for _, statusMatch := range override.StatusCodeMatches { + mapper := &envoy_filter_network_http_connection_manager_v3.ResponseMapper{} + + // Configure the body and content type + if override.Body != "" { + mapper.Body = &envoy_config_core_v3.DataSource{ + Specifier: &envoy_config_core_v3.DataSource_InlineString{ + InlineString: override.Body, + }, + } + } + + if override.ContentType != "" { + mapper.BodyFormatOverride = &envoy_config_core_v3.SubstitutionFormatString{ + Format: &envoy_config_core_v3.SubstitutionFormatString_TextFormat{ + TextFormat: "%LOCAL_REPLY_BODY%", + }, + ContentType: override.ContentType, + } + } + + // Configure the filter for status code matching + switch statusMatch.Type { + case "Value": + // For exact value match, use equals comparison + statusCodeFilter := &envoy_config_accesslog_v3.StatusCodeFilter{ + Comparison: &envoy_config_accesslog_v3.ComparisonFilter{ + Op: envoy_config_accesslog_v3.ComparisonFilter_EQ, + Value: &envoy_config_core_v3.RuntimeUInt32{ + DefaultValue: statusMatch.Value, + RuntimeKey: "", // Empty string for runtime key as it's not needed + }, + }, + } + + mapper.Filter = &envoy_config_accesslog_v3.AccessLogFilter{ + FilterSpecifier: &envoy_config_accesslog_v3.AccessLogFilter_StatusCodeFilter{ + StatusCodeFilter: statusCodeFilter, + }, + } + case "Range": + // For range match, create an AND filter with GE and LE conditions + geFilter := &envoy_config_accesslog_v3.StatusCodeFilter{ + Comparison: &envoy_config_accesslog_v3.ComparisonFilter{ + Op: envoy_config_accesslog_v3.ComparisonFilter_GE, + Value: &envoy_config_core_v3.RuntimeUInt32{ + DefaultValue: statusMatch.Start, + RuntimeKey: "", // Empty string for runtime key as it's not needed + }, + }, + } + + leFilter := &envoy_config_accesslog_v3.StatusCodeFilter{ + Comparison: &envoy_config_accesslog_v3.ComparisonFilter{ + Op: envoy_config_accesslog_v3.ComparisonFilter_LE, + Value: &envoy_config_core_v3.RuntimeUInt32{ + DefaultValue: statusMatch.End, + RuntimeKey: "", // Empty string for runtime key as it's not needed + }, + }, + } + + andFilter := &envoy_config_accesslog_v3.AndFilter{ + Filters: []*envoy_config_accesslog_v3.AccessLogFilter{ + { + FilterSpecifier: &envoy_config_accesslog_v3.AccessLogFilter_StatusCodeFilter{ + StatusCodeFilter: geFilter, + }, + }, + { + FilterSpecifier: &envoy_config_accesslog_v3.AccessLogFilter_StatusCodeFilter{ + StatusCodeFilter: leFilter, + }, + }, + }, + } + + mapper.Filter = &envoy_config_accesslog_v3.AccessLogFilter{ + FilterSpecifier: &envoy_config_accesslog_v3.AccessLogFilter_AndFilter{ + AndFilter: andFilter, + }, + } + } + + config.Mappers = append(config.Mappers, mapper) + } + } + + if len(config.Mappers) == 0 { + return nil + } + + return config +} + // unixSocketAddress creates a new Unix Socket envoy_config_core_v3.Address. func unixSocketAddress(address string) *envoy_config_core_v3.Address { return &envoy_config_core_v3.Address{ @@ -993,7 +1218,12 @@ func grpcService(clusterName, sni string, timeout timeout.Setting) *envoy_config } // ListenerFilters returns a []*envoy_config_listener_v3.ListenerFilter for the supplied listener filters. +// This is a convenience wrapper that allows for more readable code when setting the ListenerFilters field. +// If no filters are provided, it returns nil which is the proper empty value for the field. func ListenerFilters(filters ...*envoy_config_listener_v3.ListenerFilter) []*envoy_config_listener_v3.ListenerFilter { + if len(filters) == 0 { + return nil + } return filters } diff --git a/internal/envoy/v3/listener_test.go b/internal/envoy/v3/listener_test.go index 2cc564d924d..01977c3112f 100644 --- a/internal/envoy/v3/listener_test.go +++ b/internal/envoy/v3/listener_test.go @@ -2001,3 +2001,413 @@ func authzFilter(extras ...any) *envoy_filter_network_http_connection_manager_v3 AuthorizationServerWithRequestBody: body, }) } + +func TestLocalReplyConfigFromOverridePolicy(t *testing.T) { + tests := map[string]struct { + overrides []*dag.ResponseOverride + want *envoy_filter_network_http_connection_manager_v3.LocalReplyConfig + wantNil bool + }{ + "nil overrides": { + overrides: nil, + wantNil: true, + }, + "empty overrides": { + overrides: []*dag.ResponseOverride{}, + wantNil: true, + }, + "basic value match override": { + overrides: []*dag.ResponseOverride{ + { + StatusCodeMatches: []dag.StatusCodeMatch{ + { + Type: "Value", + Value: 404, + }, + }, + ContentType: "text/html", + Body: "Custom Not Found Page", + }, + }, + want: &envoy_filter_network_http_connection_manager_v3.LocalReplyConfig{ + Mappers: []*envoy_filter_network_http_connection_manager_v3.ResponseMapper{ + { + Filter: &envoy_config_accesslog_v3.AccessLogFilter{ + FilterSpecifier: &envoy_config_accesslog_v3.AccessLogFilter_StatusCodeFilter{ + StatusCodeFilter: &envoy_config_accesslog_v3.StatusCodeFilter{ + Comparison: &envoy_config_accesslog_v3.ComparisonFilter{ + Op: envoy_config_accesslog_v3.ComparisonFilter_EQ, + Value: &envoy_config_core_v3.RuntimeUInt32{ + DefaultValue: 404, + RuntimeKey: "", + }, + }, + }, + }, + }, + Body: &envoy_config_core_v3.DataSource{ + Specifier: &envoy_config_core_v3.DataSource_InlineString{ + InlineString: "Custom Not Found Page", + }, + }, + BodyFormatOverride: &envoy_config_core_v3.SubstitutionFormatString{ + Format: &envoy_config_core_v3.SubstitutionFormatString_TextFormat{ + TextFormat: "%LOCAL_REPLY_BODY%", + }, + ContentType: "text/html", + }, + }, + }, + }, + wantNil: false, + }, + "range match override": { + overrides: []*dag.ResponseOverride{ + { + StatusCodeMatches: []dag.StatusCodeMatch{ + { + Type: "Range", + Start: 500, + End: 599, + }, + }, + ContentType: "text/html", + Body: "Server Error", + }, + }, + want: &envoy_filter_network_http_connection_manager_v3.LocalReplyConfig{ + Mappers: []*envoy_filter_network_http_connection_manager_v3.ResponseMapper{ + { + Filter: &envoy_config_accesslog_v3.AccessLogFilter{ + FilterSpecifier: &envoy_config_accesslog_v3.AccessLogFilter_AndFilter{ + AndFilter: &envoy_config_accesslog_v3.AndFilter{ + Filters: []*envoy_config_accesslog_v3.AccessLogFilter{ + { + FilterSpecifier: &envoy_config_accesslog_v3.AccessLogFilter_StatusCodeFilter{ + StatusCodeFilter: &envoy_config_accesslog_v3.StatusCodeFilter{ + Comparison: &envoy_config_accesslog_v3.ComparisonFilter{ + Op: envoy_config_accesslog_v3.ComparisonFilter_GE, + Value: &envoy_config_core_v3.RuntimeUInt32{ + DefaultValue: 500, + RuntimeKey: "", + }, + }, + }, + }, + }, + { + FilterSpecifier: &envoy_config_accesslog_v3.AccessLogFilter_StatusCodeFilter{ + StatusCodeFilter: &envoy_config_accesslog_v3.StatusCodeFilter{ + Comparison: &envoy_config_accesslog_v3.ComparisonFilter{ + Op: envoy_config_accesslog_v3.ComparisonFilter_LE, + Value: &envoy_config_core_v3.RuntimeUInt32{ + DefaultValue: 599, + RuntimeKey: "", + }, + }, + }, + }, + }, + }, + }, + }, + }, + Body: &envoy_config_core_v3.DataSource{ + Specifier: &envoy_config_core_v3.DataSource_InlineString{ + InlineString: "Server Error", + }, + }, + BodyFormatOverride: &envoy_config_core_v3.SubstitutionFormatString{ + Format: &envoy_config_core_v3.SubstitutionFormatString_TextFormat{ + TextFormat: "%LOCAL_REPLY_BODY%", + }, + ContentType: "text/html", + }, + }, + }, + }, + wantNil: false, + }, + "multiple overrides": { + overrides: []*dag.ResponseOverride{ + { + StatusCodeMatches: []dag.StatusCodeMatch{ + { + Type: "Value", + Value: 404, + }, + }, + ContentType: "text/html", + Body: "Custom Not Found Page", + }, + { + StatusCodeMatches: []dag.StatusCodeMatch{ + { + Type: "Range", + Start: 500, + End: 599, + }, + }, + ContentType: "text/html", + Body: "Server Error", + }, + }, + want: &envoy_filter_network_http_connection_manager_v3.LocalReplyConfig{ + Mappers: []*envoy_filter_network_http_connection_manager_v3.ResponseMapper{ + { + Filter: &envoy_config_accesslog_v3.AccessLogFilter{ + FilterSpecifier: &envoy_config_accesslog_v3.AccessLogFilter_StatusCodeFilter{ + StatusCodeFilter: &envoy_config_accesslog_v3.StatusCodeFilter{ + Comparison: &envoy_config_accesslog_v3.ComparisonFilter{ + Op: envoy_config_accesslog_v3.ComparisonFilter_EQ, + Value: &envoy_config_core_v3.RuntimeUInt32{ + DefaultValue: 404, + RuntimeKey: "", + }, + }, + }, + }, + }, + Body: &envoy_config_core_v3.DataSource{ + Specifier: &envoy_config_core_v3.DataSource_InlineString{ + InlineString: "Custom Not Found Page", + }, + }, + BodyFormatOverride: &envoy_config_core_v3.SubstitutionFormatString{ + Format: &envoy_config_core_v3.SubstitutionFormatString_TextFormat{ + TextFormat: "%LOCAL_REPLY_BODY%", + }, + ContentType: "text/html", + }, + }, + { + Filter: &envoy_config_accesslog_v3.AccessLogFilter{ + FilterSpecifier: &envoy_config_accesslog_v3.AccessLogFilter_AndFilter{ + AndFilter: &envoy_config_accesslog_v3.AndFilter{ + Filters: []*envoy_config_accesslog_v3.AccessLogFilter{ + { + FilterSpecifier: &envoy_config_accesslog_v3.AccessLogFilter_StatusCodeFilter{ + StatusCodeFilter: &envoy_config_accesslog_v3.StatusCodeFilter{ + Comparison: &envoy_config_accesslog_v3.ComparisonFilter{ + Op: envoy_config_accesslog_v3.ComparisonFilter_GE, + Value: &envoy_config_core_v3.RuntimeUInt32{ + DefaultValue: 500, + RuntimeKey: "", + }, + }, + }, + }, + }, + { + FilterSpecifier: &envoy_config_accesslog_v3.AccessLogFilter_StatusCodeFilter{ + StatusCodeFilter: &envoy_config_accesslog_v3.StatusCodeFilter{ + Comparison: &envoy_config_accesslog_v3.ComparisonFilter{ + Op: envoy_config_accesslog_v3.ComparisonFilter_LE, + Value: &envoy_config_core_v3.RuntimeUInt32{ + DefaultValue: 599, + RuntimeKey: "", + }, + }, + }, + }, + }, + }, + }, + }, + }, + Body: &envoy_config_core_v3.DataSource{ + Specifier: &envoy_config_core_v3.DataSource_InlineString{ + InlineString: "Server Error", + }, + }, + BodyFormatOverride: &envoy_config_core_v3.SubstitutionFormatString{ + Format: &envoy_config_core_v3.SubstitutionFormatString_TextFormat{ + TextFormat: "%LOCAL_REPLY_BODY%", + }, + ContentType: "text/html", + }, + }, + }, + }, + wantNil: false, + }, + "override with no status code matches": { + overrides: []*dag.ResponseOverride{ + { + StatusCodeMatches: []dag.StatusCodeMatch{}, + ContentType: "text/html", + Body: "Custom Error Page", + }, + }, + want: &envoy_filter_network_http_connection_manager_v3.LocalReplyConfig{}, + wantNil: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := LocalReplyConfigFromOverridePolicy(tc.overrides) + switch { + case tc.wantNil && got != nil: + t.Fatalf("wanted nil, got: %v", got) + case !tc.wantNil && got == nil: + t.Fatalf("wanted non-nil, got nil") + case tc.wantNil: + // We're expecting nil and we got it. Nothing more to do. + default: + assert.Equal(t, tc.want, got) + } + }) + } +} + +func TestResponseMapperFromOverridePolicy(t *testing.T) { + tests := map[string]struct { + override *dag.ResponseOverride + want *envoy_filter_network_http_connection_manager_v3.ResponseMapper + wantNil bool + }{ + "nil override": { + override: nil, + want: &envoy_filter_network_http_connection_manager_v3.ResponseMapper{}, + wantNil: false, + }, + "value match": { + override: &dag.ResponseOverride{ + StatusCodeMatches: []dag.StatusCodeMatch{ + { + Type: "Value", + Value: 404, + }, + }, + ContentType: "text/html", + Body: "Custom Not Found Page", + }, + want: &envoy_filter_network_http_connection_manager_v3.ResponseMapper{ + Filter: &envoy_config_accesslog_v3.AccessLogFilter{ + FilterSpecifier: &envoy_config_accesslog_v3.AccessLogFilter_StatusCodeFilter{ + StatusCodeFilter: &envoy_config_accesslog_v3.StatusCodeFilter{ + Comparison: &envoy_config_accesslog_v3.ComparisonFilter{ + Op: envoy_config_accesslog_v3.ComparisonFilter_EQ, + Value: &envoy_config_core_v3.RuntimeUInt32{ + DefaultValue: 404, + RuntimeKey: "", + }, + }, + }, + }, + }, + Body: &envoy_config_core_v3.DataSource{ + Specifier: &envoy_config_core_v3.DataSource_InlineString{ + InlineString: "Custom Not Found Page", + }, + }, + BodyFormatOverride: &envoy_config_core_v3.SubstitutionFormatString{ + Format: &envoy_config_core_v3.SubstitutionFormatString_TextFormat{ + TextFormat: "%LOCAL_REPLY_BODY%", + }, + ContentType: "text/html", + }, + }, + wantNil: false, + }, + "range match": { + override: &dag.ResponseOverride{ + StatusCodeMatches: []dag.StatusCodeMatch{ + { + Type: "Range", + Start: 500, + End: 599, + }, + }, + ContentType: "application/json", + Body: `{"error":"Server Error","code":500}`, + }, + want: &envoy_filter_network_http_connection_manager_v3.ResponseMapper{ + Filter: &envoy_config_accesslog_v3.AccessLogFilter{ + FilterSpecifier: &envoy_config_accesslog_v3.AccessLogFilter_AndFilter{ + AndFilter: &envoy_config_accesslog_v3.AndFilter{ + Filters: []*envoy_config_accesslog_v3.AccessLogFilter{ + { + FilterSpecifier: &envoy_config_accesslog_v3.AccessLogFilter_StatusCodeFilter{ + StatusCodeFilter: &envoy_config_accesslog_v3.StatusCodeFilter{ + Comparison: &envoy_config_accesslog_v3.ComparisonFilter{ + Op: envoy_config_accesslog_v3.ComparisonFilter_GE, + Value: &envoy_config_core_v3.RuntimeUInt32{ + DefaultValue: 500, + RuntimeKey: "", + }, + }, + }, + }, + }, + { + FilterSpecifier: &envoy_config_accesslog_v3.AccessLogFilter_StatusCodeFilter{ + StatusCodeFilter: &envoy_config_accesslog_v3.StatusCodeFilter{ + Comparison: &envoy_config_accesslog_v3.ComparisonFilter{ + Op: envoy_config_accesslog_v3.ComparisonFilter_LE, + Value: &envoy_config_core_v3.RuntimeUInt32{ + DefaultValue: 599, + RuntimeKey: "", + }, + }, + }, + }, + }, + }, + }, + }, + }, + Body: &envoy_config_core_v3.DataSource{ + Specifier: &envoy_config_core_v3.DataSource_InlineString{ + InlineString: `{"error":"Server Error","code":500}`, + }, + }, + BodyFormatOverride: &envoy_config_core_v3.SubstitutionFormatString{ + Format: &envoy_config_core_v3.SubstitutionFormatString_TextFormat{ + TextFormat: "%LOCAL_REPLY_BODY%", + }, + ContentType: "application/json", + }, + }, + wantNil: false, + }, + "no status code matches": { + override: &dag.ResponseOverride{ + StatusCodeMatches: []dag.StatusCodeMatch{}, + ContentType: "text/html", + Body: "Custom Error Page", + }, + want: &envoy_filter_network_http_connection_manager_v3.ResponseMapper{ + Body: &envoy_config_core_v3.DataSource{ + Specifier: &envoy_config_core_v3.DataSource_InlineString{ + InlineString: "Custom Error Page", + }, + }, + BodyFormatOverride: &envoy_config_core_v3.SubstitutionFormatString{ + Format: &envoy_config_core_v3.SubstitutionFormatString_TextFormat{ + TextFormat: "%LOCAL_REPLY_BODY%", + }, + ContentType: "text/html", + }, + }, + wantNil: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := ResponseMapperFromOverridePolicy(tc.override) + switch { + case tc.wantNil && got != nil: + t.Fatalf("wanted nil, got: %v", got) + case !tc.wantNil && got == nil: + t.Fatalf("wanted non-nil, got nil") + case tc.wantNil: + // We're expecting nil and we got it. Nothing more to do. + default: + assert.Equal(t, tc.want, got) + } + }) + } +} diff --git a/internal/featuretests/v3/directresponsepolicy_test.go b/internal/featuretests/v3/directresponsepolicy_test.go index c6375cf881a..01f94794c51 100644 --- a/internal/featuretests/v3/directresponsepolicy_test.go +++ b/internal/featuretests/v3/directresponsepolicy_test.go @@ -16,13 +16,16 @@ package v3 import ( "testing" + envoy_config_accesslog_v3 "github.com/envoyproxy/go-control-plane/envoy/config/accesslog/v3" envoy_config_core_v3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" envoy_config_route_v3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3" + envoy_filter_network_http_connection_manager_v3 "github.com/envoyproxy/go-control-plane/envoy/extensions/filters/network/http_connection_manager/v3" envoy_service_discovery_v3 "github.com/envoyproxy/go-control-plane/envoy/service/discovery/v3" core_v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/util/intstr" contour_v1 "github.com/projectcontour/contour/apis/projectcontour/v1" + contour_v1alpha1 "github.com/projectcontour/contour/apis/projectcontour/v1alpha1" envoy_v3 "github.com/projectcontour/contour/internal/envoy/v3" "github.com/projectcontour/contour/internal/fixture" ) @@ -126,3 +129,107 @@ func TestDirectResponsePolicy_HTTProxy(t *testing.T) { TypeUrl: routeType, }) } + +func TestCustomErrorPagePolicy_HTTPProxy(t *testing.T) { + rh, c, done := setup(t) + defer done() + + rh.OnAdd(fixture.NewService("svc1"). + WithPorts(core_v1.ServicePort{Port: 80, TargetPort: intstr.FromInt(8080)}), + ) + + // Create an HTTPProxy with a ResponseOverride for 503 errors + errorPageProxy := fixture.NewProxy("custom-error-page").WithSpec( + contour_v1.HTTPProxySpec{ + VirtualHost: &contour_v1.VirtualHost{Fqdn: "errorpage.projectcontour.io"}, + Routes: []contour_v1.Route{{ + Services: []contour_v1.Service{{ + Name: "svc1", + Port: 80, + }}, + ResponseOverridePolicy: []contour_v1.HTTPResponseOverridePolicy{{ + Match: contour_v1.ResponseOverrideMatch{ + StatusCodes: []contour_v1.StatusCodeMatch{{ + Type: "Value", + Value: 503, + }}, + }, + Response: contour_v1.ResponseOverrideResponse{ + ContentType: "text/html", + Body: contour_v1.ResponseBodyConfig{ + Type: "Inline", + Inline: "+(Appears on: +Route) +
++
HTTPResponseOverridePolicy defines configuration for overriding responses from backends based on status codes
+ +Field | +Description | +
---|---|
+match
++ + +ResponseOverrideMatch + + + |
+
+ Match defines the conditions under which the response should be overridden + |
+
+response
++ + +ResponseOverrideResponse + + + |
+
+ Response defines how to override the response + |
+
@@ -3447,6 +3494,132 @@
+(Appears on: +ResponseOverrideResponse) +
++
ResponseBodyConfig defines the response body to use in response override
+ +Field | +Description | +
---|---|
+type
++ +string + + |
+
+ Type specifies the type of body content to be used + |
+
+inline
++ +string + + |
+
+(Optional)
+ Inline provides the content directly in the HTTPProxy resource +A 4KB size limit is enforced to balance having sufficiently expressive content +for custom responses while avoiding overwhelming bloat in CRDs. + |
+
+(Appears on: +HTTPResponseOverridePolicy) +
++
ResponseOverrideMatch defines the conditions under which responses should be modified
+ +Field | +Description | +
---|---|
+statusCodes
++ + +[]StatusCodeMatch + + + |
+
+ StatusCodes defines the HTTP status codes to match + |
+
+(Appears on: +HTTPResponseOverridePolicy) +
++
ResponseOverrideResponse defines how the response should be modified
+ +Field | +Description | +
---|---|
+contentType
++ +string + + |
+
+(Optional)
+ ContentType defines the Content-Type header value +If not specified, “text/plain” is used by default + |
+
+body
++ + +ResponseBodyConfig + + + |
+
+ Body defines the content of the response body + |
+
string
alias)@@ -3892,6 +4065,21 @@
responseOverridePolicy
+ResponseOverridePolicy defines how to override responses from backends based on status codes.
++(Appears on: +ResponseOverrideMatch) +
++
StatusCodeMatch defines a status code match condition
+ +Field | +Description | +
---|---|
+type
++ + +StatusCodeType + + + |
+
+ Type specifies how to match the status code + |
+
+value
++ +int + + |
+
+(Optional)
+ Value is used when Type is Value to specify an exact status code + |
+
+range
++ + +StatusCodeRange + + + |
+
+(Optional)
+ Range is used when Type is Range to specify a range of status codes + |
+
+(Appears on: +StatusCodeMatch) +
++
StatusCodeRange defines a range of status codes to match
+ +Field | +Description | +
---|---|
+start
++ +int + + |
+
+ Start is the beginning of the status code range (inclusive) + |
+
+end
++ +int + + |
+
+ End is the end of the status code range (inclusive) + |
+
string
alias)+(Appears on: +StatusCodeMatch) +
++
StatusCodeType defines the type of status code match in HTTPResponseOverridePolicy
+ ++
StatusCodeValue defines a single status code to match
+ +Field | +Description | +
---|---|
+value
++ +int + + |
+
+ Value is the exact status code to match + |
+
diff --git a/site/content/docs/main/config/request-routing.md b/site/content/docs/main/config/request-routing.md index 19ef5386e86..a079415d611 100644 --- a/site/content/docs/main/config/request-routing.md +++ b/site/content/docs/main/config/request-routing.md @@ -325,7 +325,7 @@ Timeout will not trigger while HTTP/1.1 connection is idle between two consecuti If not specified, there is no per-route idle timeout, though a connection manager-wide stream idle timeout default of 5m still applies. More information can be found in [Envoy's documentation][6]. - `timeoutPolicy.idleConnection` Timeout for how long connection from the proxy to the upstream service is kept when there are no active requests. -If not supplied, Envoy’s default value of 1h applies. +If not supplied, Envoy's default value of 1h applies. More information can be found in [Envoy's documentation][8]. TimeoutPolicy durations are expressed in the Go [Duration format][5]. @@ -525,6 +525,98 @@ In this example, a sample redirect flow might look like this: See [the API specification][9] and [Envoy's documentation][10] for more detail. +## Response Override Policy + +HTTPProxy supports overriding responses for errors generated by Envoy itself through the ResponseOverridePolicy feature. This allows you to customize error responses for scenarios such as unreachable services, timeout errors, or directly generated responses. The ResponseOverridePolicy operates on Envoy's "local replies" rather than modifying responses that come directly from your backend services. + +> **Important**: ResponseOverridePolicy only applies to error responses generated by Envoy/Contour itself, not to error responses returned by backend services. For example, it works for 503 Service Unavailable errors when a service is down, but not for 404 Not Found errors that your application returns. + +```yaml +apiVersion: projectcontour.io/v1 +kind: HTTPProxy +metadata: + name: response-override-example + namespace: default +spec: + virtualhost: + fqdn: example.com + routes: + - conditions: + - prefix: /api + services: + - name: api-service + port: 80 + responseOverridePolicy: + - match: + statusCodes: + - type: Value + value: 404 + response: + contentType: text/plain + body: + type: Inline + inline: "The requested resource was not found." + - match: + statusCodes: + - type: Range + range: + start: 500 + end: 599 + response: + contentType: text/html + body: + type: Inline + inline: "
Please try again later.
" +``` + +In the example above: +- 404 responses generated by Envoy/Contour (such as from `directResponsePolicy` or route not found) will be replaced with a custom plain text message +- 5xx errors generated by Envoy (such as when a service is unreachable) will be replaced with a custom HTML error page + +Response overrides can be defined on a per-route basis. You can specify multiple match conditions and responses within a single route. + +### When ResponseOverridePolicy Applies + +The policy applies in scenarios such as: + +1. **Service Unavailability**: When a backend service is down or unreachable, Envoy generates a 503 Service Unavailable error. +2. **Timeouts**: When request or response timeouts occur. +3. **Direct Responses**: When using `directResponsePolicy` to generate responses from Contour directly. +4. **TLS/Certificate Errors**: When TLS errors occur. + +It does NOT apply to: +1. Error responses returned directly from your backend services. +2. Regular successful (2xx) responses from backends. + +### Match Configuration + +The `match` field supports two types of status code matches: + +1. **Value**: Match a specific status code + ```yaml + statusCodes: + - type: Value + value: 404 + ``` + +2. **Range**: Match a range of status codes + ```yaml + statusCodes: + - type: Range + range: + start: 500 + end: 599 + ``` + +### Response Configuration + +The `response` field defines the content to return when a match occurs: + +- `contentType`: Specifies the Content-Type header of the response (defaults to "text/plain" if omitted) +- `body`: Defines the content of the response body + - `type`: Currently only "Inline" is supported + - `inline`: The actual content to return in the response + [3]: /docs/{{< param version >}}/config/api/#projectcontour.io/v1.HTTPRequestRedirectPolicy [4]: https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#envoy-v3-api-field-config-route-v3-routeaction-timeout [5]: https://godoc.org/time#ParseDuration @@ -533,3 +625,4 @@ See [the API specification][9] and [Envoy's documentation][10] for more detail. [8]: https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/core/v3/protocol.proto#envoy-v3-api-field-config-core-v3-httpprotocoloptions-idle-timeout [9] /docs/{{< param version >}}/config/api/#projectcontour.io/v1.HTTPInternalRedirectPolicy [10] https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/http/http_connection_management.html#internal-redirects +[11] https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/local_reply_config_filter diff --git a/test/e2e/httpproxy/httpproxy_test.go b/test/e2e/httpproxy/httpproxy_test.go index 0a838a2294a..f79a4ba910d 100644 --- a/test/e2e/httpproxy/httpproxy_test.go +++ b/test/e2e/httpproxy/httpproxy_test.go @@ -170,6 +170,8 @@ var _ = Describe("HTTPProxy", func() { f.NamespacedTest("httpproxy-retry-policy-validation", testRetryPolicyValidation) + f.NamespacedTest("httpproxy-response-override-policy", testResponseOverridePolicy) + f.NamespacedTest("httpproxy-wildcard-subdomain-fqdn", testWildcardSubdomainFQDN) f.NamespacedTest("httpproxy-ingress-wildcard-override", testIngressWildcardSubdomainFQDN) diff --git a/test/e2e/httpproxy/responseoverridepolicy_test.go b/test/e2e/httpproxy/responseoverridepolicy_test.go new file mode 100644 index 00000000000..420c47b5ee0 --- /dev/null +++ b/test/e2e/httpproxy/responseoverridepolicy_test.go @@ -0,0 +1,159 @@ +// Copyright Project Contour Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build e2e + +package httpproxy + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + + contour_v1 "github.com/projectcontour/contour/apis/projectcontour/v1" + "github.com/projectcontour/contour/test/e2e" +) + +func testResponseOverridePolicy(namespace string) { + Specify("response overrides can be configured", func() { + t := f.T() + + f.Fixtures.Echo.Deploy(namespace, "echo") + + // Create a simple HTTPProxy with ResponseOverridePolicy + proxy := &contour_v1.HTTPProxy{ + ObjectMeta: meta_v1.ObjectMeta{ + Namespace: namespace, + Name: "response-override", + }, + Spec: contour_v1.HTTPProxySpec{ + VirtualHost: &contour_v1.VirtualHost{ + Fqdn: "response-override.projectcontour.io", + }, + Routes: []contour_v1.Route{ + { + Conditions: []contour_v1.MatchCondition{ + { + Prefix: "/", + }, + }, + Services: []contour_v1.Service{ + { + Name: "echo", + Port: 80, + }, + }, + ResponseOverridePolicy: []contour_v1.HTTPResponseOverridePolicy{ + { + // Match 404 responses + Match: contour_v1.ResponseOverrideMatch{ + StatusCodes: []contour_v1.StatusCodeMatch{ + { + Type: "Value", + Value: 404, + }, + }, + }, + Response: contour_v1.ResponseOverrideResponse{ + ContentType: "text/html", + Body: contour_v1.ResponseBodyConfig{ + Type: "Inline", + Inline: "