diff --git a/internal/controller/gateway_controller.go b/internal/controller/gateway_controller.go index 86dc883..1210673 100644 --- a/internal/controller/gateway_controller.go +++ b/internal/controller/gateway_controller.go @@ -1860,18 +1860,32 @@ func (r *GatewayReconciler) processDownstreamHTTPRouteRules( if appProtocol != nil && *appProtocol == "https" { var hostname *gatewayv1.PreciseHostname - // Fall back to looking at rule filters for a hostname. - for _, filter := range rule.Filters { - if filter.URLRewrite != nil { - hostname = filter.URLRewrite.Hostname - break + + // Prefer the cert hostname recorded by the httpproxy + // controller on the upstream EndpointSlice. URLRewrite + // may now carry a user-supplied Host header override + // instead of the backend FQDN, so it's no longer a + // reliable source for BackendTLSPolicy SAN validation. + if v, ok := upstreamEndpointSlice.Annotations[BackendCertHostnameAnnotation]; ok && v != "" { + hostname = ptr.To(gatewayv1.PreciseHostname(v)) + } + + // Fall back to looking at rule filters for a hostname + // (preserves behaviour for EndpointSlices that predate + // the annotation). + if hostname == nil { + for _, filter := range rule.Filters { + if filter.URLRewrite != nil { + hostname = filter.URLRewrite.Hostname + break + } } } if hostname == nil { // TODO(jreese) set the RouteConditionResolvedRefs condition to // False, as the hostname is not present. - return nil, nil, nil, fmt.Errorf("no hostname found in URLRewrite filters on backendRef or Route %q", upstreamRoute.Name) + return nil, nil, nil, fmt.Errorf("no hostname found in URLRewrite filters or EndpointSlice annotation on backendRef or Route %q", upstreamRoute.Name) } backendTLSPolicy := &gatewayv1alpha3.BackendTLSPolicy{ diff --git a/internal/controller/httpproxy_controller.go b/internal/controller/httpproxy_controller.go index bb76054..03b40c0 100644 --- a/internal/controller/httpproxy_controller.go +++ b/internal/controller/httpproxy_controller.go @@ -65,6 +65,14 @@ type desiredHTTPProxyResources struct { const httpProxyFinalizer = "networking.datumapis.com/httpproxy-cleanup" const connectorOfflineFilterPrefix = "connector-offline" +// BackendCertHostnameAnnotation is set on the upstream EndpointSlice by the +// HTTPProxy controller to record the hostname expected on the backend's TLS +// certificate. The gateway controller reads it when building a +// BackendTLSPolicy so SAN validation continues to target the real backend +// FQDN even when URLRewrite.Hostname has been redirected to a user-supplied +// Host header override. +const BackendCertHostnameAnnotation = "networking.datumapis.com/backend-cert-hostname" + const ( SchemeHTTP = "http" SchemeHTTPS = "https" @@ -286,6 +294,19 @@ func (r *HTTPProxyReconciler) Reconcile(ctx context.Context, req mcreconcile.Req endpointSlice.AddressType = desiredEndpointSlice.AddressType endpointSlice.Endpoints = desiredEndpointSlice.Endpoints endpointSlice.Ports = desiredEndpointSlice.Ports + + // Keep the backend cert hostname annotation in sync. The gateway + // controller reads this to build the BackendTLSPolicy when the + // URLRewrite filter carries a user Host override instead of the + // real backend FQDN. + if v, ok := desiredEndpointSlice.Annotations[BackendCertHostnameAnnotation]; ok { + if endpointSlice.Annotations == nil { + endpointSlice.Annotations = map[string]string{} + } + endpointSlice.Annotations[BackendCertHostnameAnnotation] = v + } else { + delete(endpointSlice.Annotations, BackendCertHostnameAnnotation) + } return nil }) @@ -624,6 +645,65 @@ func httpProxyReferencesConnector(httpProxy *networkingv1alpha.HTTPProxy, connec return false } +// extractHostHeaderOverride returns the Host header value from a +// RequestHeaderModifier filter, if present. Header names are matched +// case-insensitively per RFC 7230. The returned bool indicates whether a +// Host header override was found. +// +// Envoy Gateway does not accept Host header manipulation via +// RequestHeaderModifier — it must go through URLRewrite.Hostname instead. +// collectDesiredResources uses this helper to translate the user-facing +// RequestHeaderModifier{Host} shape (which round-trips with datumctl and +// the cloud portal) into the URLRewrite{Hostname} that Envoy actually +// honours at egress. +func extractHostHeaderOverride(filters []gatewayv1.HTTPRouteFilter) (string, bool) { + for _, filter := range filters { + if filter.Type != gatewayv1.HTTPRouteFilterRequestHeaderModifier || filter.RequestHeaderModifier == nil { + continue + } + for _, h := range filter.RequestHeaderModifier.Set { + if strings.EqualFold(string(h.Name), "Host") { + return h.Value, true + } + } + } + return "", false +} + +// stripHostFromRequestHeaderModifier returns the filter list with any +// Host entry removed from each RequestHeaderModifier's Set list. If a +// RequestHeaderModifier ends up empty (no add/set/remove), the filter +// itself is dropped. This keeps Envoy Gateway from rejecting the route +// because of an "empty" RequestHeaderModifier after we've moved the +// Host override into URLRewrite. +func stripHostFromRequestHeaderModifier(filters []gatewayv1.HTTPRouteFilter) []gatewayv1.HTTPRouteFilter { + out := make([]gatewayv1.HTTPRouteFilter, 0, len(filters)) + for _, filter := range filters { + if filter.Type != gatewayv1.HTTPRouteFilterRequestHeaderModifier || filter.RequestHeaderModifier == nil { + out = append(out, filter) + continue + } + modifier := filter.RequestHeaderModifier + filtered := make([]gatewayv1.HTTPHeader, 0, len(modifier.Set)) + for _, h := range modifier.Set { + if strings.EqualFold(string(h.Name), "Host") { + continue + } + filtered = append(filtered, h) + } + if len(filtered) == 0 && len(modifier.Add) == 0 && len(modifier.Remove) == 0 { + // Drop the now-empty RequestHeaderModifier filter entirely. + continue + } + newFilter := filter + newModifier := *modifier + newModifier.Set = filtered + newFilter.RequestHeaderModifier = &newModifier + out = append(out, newFilter) + } + return out +} + func (r *HTTPProxyReconciler) collectDesiredResources( ctx context.Context, cl client.Client, @@ -782,17 +862,47 @@ func (r *HTTPProxyReconciler) collectDesiredResources( } } + // Resolve the user's Host header override, if any. Envoy Gateway + // rejects RequestHeaderModifier filters that touch Host; the Host + // rewrite must be expressed as URLRewrite.Hostname instead. We + // translate the user-facing RequestHeaderModifier{Host} shape + // (which is what datumctl and the cloud portal write) into the + // URLRewrite.Hostname value Envoy will honour at egress, then + // strip the now-redundant Host entry from the RequestHeaderModifier + // so EG doesn't see the conflicting combination. + userHostOverride, hasUserHost := extractHostHeaderOverride(ruleFilters) + if !hasUserHost { + userHostOverride, hasUserHost = extractHostHeaderOverride(backend.Filters) + } + if hasUserHost { + ruleFilters = stripHostFromRequestHeaderModifier(ruleFilters) + backend.Filters = stripHostFromRequestHeaderModifier(backend.Filters) + } + + // Track the backend cert hostname separately from the Host + // rewrite value. The two can diverge when the user sets a Host + // override — URLRewrite.Hostname carries the user's value to + // Envoy, while certHostname (propagated via an EndpointSlice + // annotation and read by the gateway controller) is used for + // BackendTLSPolicy SAN validation against the real backend. + var certHostname string + // For HTTPS endpoints with IP addresses, require tls.hostname for certificate validation // and use it as the Host header for the upstream request. if u.Scheme == "https" && isIPAddress { if backend.TLS == nil || backend.TLS.Hostname == nil || *backend.TLS.Hostname == "" { return nil, fmt.Errorf("HTTPS endpoint with IP address requires tls.hostname for backend %d in rule %d", backendIndex, ruleIndex) } - // Use tls.hostname for the Host header rewrite + certHostname = *backend.TLS.Hostname + rewriteHostname := certHostname + if hasUserHost { + rewriteHostname = userHostOverride + } + // Use tls.hostname (or the user override) for the Host header rewrite hostnameRewriteFound := false for i, filter := range ruleFilters { if filter.Type == gatewayv1.HTTPRouteFilterURLRewrite { - ruleFilters[i].URLRewrite.Hostname = ptr.To(gatewayv1.PreciseHostname(*backend.TLS.Hostname)) + ruleFilters[i].URLRewrite.Hostname = ptr.To(gatewayv1.PreciseHostname(rewriteHostname)) hostnameRewriteFound = true break } @@ -801,16 +911,23 @@ func (r *HTTPProxyReconciler) collectDesiredResources( ruleFilters = append(ruleFilters, gatewayv1.HTTPRouteFilter{ Type: gatewayv1.HTTPRouteFilterURLRewrite, URLRewrite: &gatewayv1.HTTPURLRewriteFilter{ - Hostname: ptr.To(gatewayv1.PreciseHostname(*backend.TLS.Hostname)), + Hostname: ptr.To(gatewayv1.PreciseHostname(rewriteHostname)), }, }) } } else if !isIPAddress && backend.Connector == nil { - // For FQDN endpoints, rewrite the Host header to match the backend hostname + // For FQDN endpoints, rewrite the Host header to match the + // backend hostname — or to the user's override if they set + // one via RequestHeaderModifier. + certHostname = host + rewriteHostname := host + if hasUserHost { + rewriteHostname = userHostOverride + } hostnameRewriteFound := false for i, filter := range ruleFilters { if filter.Type == gatewayv1.HTTPRouteFilterURLRewrite { - ruleFilters[i].URLRewrite.Hostname = ptr.To(gatewayv1.PreciseHostname(host)) + ruleFilters[i].URLRewrite.Hostname = ptr.To(gatewayv1.PreciseHostname(rewriteHostname)) hostnameRewriteFound = true break } @@ -820,16 +937,25 @@ func (r *HTTPProxyReconciler) collectDesiredResources( ruleFilters = append(ruleFilters, gatewayv1.HTTPRouteFilter{ Type: gatewayv1.HTTPRouteFilterURLRewrite, URLRewrite: &gatewayv1.HTTPURLRewriteFilter{ - Hostname: ptr.To(gatewayv1.PreciseHostname(host)), + Hostname: ptr.To(gatewayv1.PreciseHostname(rewriteHostname)), }, }) } } + epAnnotations := map[string]string{} + if certHostname != "" { + // Surface the backend cert hostname so the gateway controller + // can build the BackendTLSPolicy without relying on the + // URLRewrite filter (which may now carry a user-supplied Host + // override instead of the real backend FQDN). + epAnnotations[BackendCertHostnameAnnotation] = certHostname + } endpointSlice := &discoveryv1.EndpointSlice{ ObjectMeta: metav1.ObjectMeta{ - Namespace: httpProxy.Namespace, - Name: fmt.Sprintf("%s-%d-%d", httpProxy.Name, ruleIndex, backendIndex), + Namespace: httpProxy.Namespace, + Name: fmt.Sprintf("%s-%d-%d", httpProxy.Name, ruleIndex, backendIndex), + Annotations: epAnnotations, }, AddressType: addressType, Endpoints: []discoveryv1.Endpoint{ diff --git a/internal/controller/httpproxy_controller_test.go b/internal/controller/httpproxy_controller_test.go index 129ad68..cc83387 100644 --- a/internal/controller/httpproxy_controller_test.go +++ b/internal/controller/httpproxy_controller_test.go @@ -276,6 +276,148 @@ func TestHTTPProxyCollectDesiredResources(t *testing.T) { } }, }, + { + name: "user Host header override on FQDN backend rewrites URLRewrite hostname", + httpProxy: newHTTPProxy(func(h *networkingv1alpha.HTTPProxy) { + h.Spec.Rules[0].Filters = []gatewayv1.HTTPRouteFilter{ + { + Type: gatewayv1.HTTPRouteFilterRequestHeaderModifier, + RequestHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Set: []gatewayv1.HTTPHeader{ + {Name: "Host", Value: "example.internal"}, + }, + }, + }, + } + }), + assert: func(t *testing.T, httpProxy *networkingv1alpha.HTTPProxy, desiredResources *desiredHTTPProxyResources) { + routeRule := desiredResources.httpRoute.Spec.Rules[0] + // URLRewrite must carry the user's Host value (Envoy's + // host_rewrite_literal); RequestHeaderModifier{Host} must + // have been stripped (EG rejects it on Host). + var urlRewrite *gatewayv1.HTTPRouteFilter + for i := range routeRule.Filters { + if routeRule.Filters[i].Type == gatewayv1.HTTPRouteFilterURLRewrite { + urlRewrite = &routeRule.Filters[i] + } + if routeRule.Filters[i].Type == gatewayv1.HTTPRouteFilterRequestHeaderModifier && + routeRule.Filters[i].RequestHeaderModifier != nil { + for _, h := range routeRule.Filters[i].RequestHeaderModifier.Set { + assert.NotEqual(t, "Host", string(h.Name), "Host must be stripped from RequestHeaderModifier") + } + } + } + if assert.NotNil(t, urlRewrite, "URLRewrite must be present to express the Host rewrite") { + assert.Equal(t, "example.internal", string(ptr.Deref(urlRewrite.URLRewrite.Hostname, ""))) + } + // Cert hostname must be the real backend FQDN, not the user override. + if assert.Len(t, desiredResources.endpointSlices, 1) { + assert.Equal(t, "www.example.com", + desiredResources.endpointSlices[0].Annotations[BackendCertHostnameAnnotation]) + } + }, + }, + { + name: "user Host header override is case-insensitive", + httpProxy: newHTTPProxy(func(h *networkingv1alpha.HTTPProxy) { + h.Spec.Rules[0].Filters = []gatewayv1.HTTPRouteFilter{ + { + Type: gatewayv1.HTTPRouteFilterRequestHeaderModifier, + RequestHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Set: []gatewayv1.HTTPHeader{ + {Name: "host", Value: "example.internal"}, + }, + }, + }, + } + }), + assert: func(t *testing.T, httpProxy *networkingv1alpha.HTTPProxy, desiredResources *desiredHTTPProxyResources) { + routeRule := desiredResources.httpRoute.Spec.Rules[0] + foundURLRewriteWithOverride := false + for _, f := range routeRule.Filters { + if f.Type == gatewayv1.HTTPRouteFilterURLRewrite && + string(ptr.Deref(f.URLRewrite.Hostname, "")) == "example.internal" { + foundURLRewriteWithOverride = true + } + } + assert.True(t, foundURLRewriteWithOverride, "URLRewrite must use the user's value (case-insensitive Host match)") + }, + }, + { + name: "user Host header override at backend level rewrites URLRewrite hostname", + httpProxy: newHTTPProxy(func(h *networkingv1alpha.HTTPProxy) { + h.Spec.Rules[0].Filters = nil + h.Spec.Rules[0].Backends[0].Filters = []gatewayv1.HTTPRouteFilter{ + { + Type: gatewayv1.HTTPRouteFilterRequestHeaderModifier, + RequestHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Set: []gatewayv1.HTTPHeader{ + {Name: "Host", Value: "example.internal"}, + }, + }, + }, + } + }), + assert: func(t *testing.T, httpProxy *networkingv1alpha.HTTPProxy, desiredResources *desiredHTTPProxyResources) { + routeRule := desiredResources.httpRoute.Spec.Rules[0] + var urlRewrite *gatewayv1.HTTPRouteFilter + for i := range routeRule.Filters { + if routeRule.Filters[i].Type == gatewayv1.HTTPRouteFilterURLRewrite { + urlRewrite = &routeRule.Filters[i] + } + } + if assert.NotNil(t, urlRewrite) { + assert.Equal(t, "example.internal", string(ptr.Deref(urlRewrite.URLRewrite.Hostname, ""))) + } + // Backend-level filter should have Host stripped too. + for _, br := range routeRule.BackendRefs { + for _, f := range br.Filters { + if f.Type == gatewayv1.HTTPRouteFilterRequestHeaderModifier && f.RequestHeaderModifier != nil { + for _, h := range f.RequestHeaderModifier.Set { + assert.NotEqual(t, "Host", string(h.Name)) + } + } + } + } + }, + }, + { + name: "user Host header override on HTTPS IP backend rewrites URLRewrite hostname", + httpProxy: newHTTPProxy(func(h *networkingv1alpha.HTTPProxy) { + h.Spec.Rules[0].Filters = []gatewayv1.HTTPRouteFilter{ + { + Type: gatewayv1.HTTPRouteFilterRequestHeaderModifier, + RequestHeaderModifier: &gatewayv1.HTTPHeaderFilter{ + Set: []gatewayv1.HTTPHeader{ + {Name: "Host", Value: "example.internal"}, + }, + }, + }, + } + h.Spec.Rules[0].Backends[0].Endpoint = "https://10.0.0.1" + h.Spec.Rules[0].Backends[0].TLS = &networkingv1alpha.HTTPProxyBackendTLS{ + Hostname: ptr.To("api.example.com"), + } + }), + assert: func(t *testing.T, httpProxy *networkingv1alpha.HTTPProxy, desiredResources *desiredHTTPProxyResources) { + routeRule := desiredResources.httpRoute.Spec.Rules[0] + var urlRewrite *gatewayv1.HTTPRouteFilter + for i := range routeRule.Filters { + if routeRule.Filters[i].Type == gatewayv1.HTTPRouteFilterURLRewrite { + urlRewrite = &routeRule.Filters[i] + } + } + if assert.NotNil(t, urlRewrite) { + // URLRewrite carries the user's Host override. + assert.Equal(t, "example.internal", string(ptr.Deref(urlRewrite.URLRewrite.Hostname, ""))) + } + // Cert hostname annotation must carry the real cert SAN (tls.hostname). + if assert.Len(t, desiredResources.endpointSlices, 1) { + assert.Equal(t, "api.example.com", + desiredResources.endpointSlices[0].Annotations[BackendCertHostnameAnnotation]) + } + }, + }, { name: "custom hostnames", httpProxy: newHTTPProxy(func(h *networkingv1alpha.HTTPProxy) {