diff --git a/internal/controller/httproute_controller.go b/internal/controller/httproute_controller.go index ab2a38cfc..c7d346546 100644 --- a/internal/controller/httproute_controller.go +++ b/internal/controller/httproute_controller.go @@ -17,8 +17,10 @@ import ( "fmt" "strings" + "github.com/api7/gopkg/pkg/log" "github.com/go-logr/logr" "github.com/pkg/errors" + "go.uber.org/zap" "golang.org/x/exp/slices" corev1 "k8s.io/api/core/v1" discoveryv1 "k8s.io/api/discovery/v1" @@ -201,11 +203,24 @@ func (r *HTTPRouteReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( } ProcessBackendTrafficPolicy(r.Client, r.Log, tctx) - if err := r.Provider.Update(ctx, tctx, hr); err != nil { + filteredHTTPRoute, err := filterHostnames(gateways, hr.DeepCopy()) + if err != nil { acceptStatus.status = false acceptStatus.msg = err.Error() } + if isRouteAccepted(gateways) && err == nil { + routeToUpdate := hr + if filteredHTTPRoute != nil { + log.Debugw("filteredHTTPRoute", zap.Any("filteredHTTPRoute", filteredHTTPRoute)) + routeToUpdate = filteredHTTPRoute + } + if err := r.Provider.Update(ctx, tctx, routeToUpdate); err != nil { + acceptStatus.status = false + acceptStatus.msg = err.Error() + } + } + // TODO: diff the old and new status hr.Status.Parents = make([]gatewayv1.RouteParentStatus, 0, len(gateways)) for _, gateway := range gateways { @@ -214,9 +229,6 @@ func (r *HTTPRouteReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( for _, condition := range gateway.Conditions { parentStatus.Conditions = MergeCondition(parentStatus.Conditions, condition) } - if gateway.ListenerName == "" { - continue - } SetRouteConditionAccepted(&parentStatus, hr.GetGeneration(), acceptStatus.status, acceptStatus.msg) SetRouteConditionResolvedRefs(&parentStatus, hr.GetGeneration(), resolveRefStatus.status, resolveRefStatus.msg) hr.Status.Parents = append(hr.Status.Parents, parentStatus) diff --git a/internal/controller/utils.go b/internal/controller/utils.go index 8c784980e..1667e3c66 100644 --- a/internal/controller/utils.go +++ b/internal/controller/utils.go @@ -14,6 +14,7 @@ package controller import ( "context" + "errors" "fmt" "path" "reflect" @@ -48,6 +49,10 @@ const ( const defaultIngressClassAnnotation = "ingressclass.kubernetes.io/is-default-class" +var ( + ErrNoMatchingListenerHostname = errors.New("no matching hostnames in listener") +) + // IsDefaultIngressClass returns whether an IngressClass is the default IngressClass. func IsDefaultIngressClass(obj client.Object) bool { if ingressClass, ok := obj.(*networkingv1.IngressClass); ok { @@ -229,10 +234,15 @@ func SetRouteConditionAccepted(routeParentStatus *gatewayv1.RouteParentStatus, g conditionStatus = metav1.ConditionFalse } + reason := gatewayv1.RouteReasonAccepted + if message == ErrNoMatchingListenerHostname.Error() { + reason = gatewayv1.RouteReasonNoMatchingListenerHostname + } + condition := metav1.Condition{ Type: string(gatewayv1.RouteConditionAccepted), Status: conditionStatus, - Reason: string(gatewayv1.RouteReasonAccepted), + Reason: string(reason), ObservedGeneration: generation, Message: message, LastTransitionTime: metav1.Now(), @@ -458,39 +468,51 @@ func HostnamesIntersect(a, b string) bool { return HostnamesMatch(a, b) || HostnamesMatch(b, a) } +// HostnamesMatch checks that the hostnameB matches the hostnameA. HostnameA is treated as mask +// to be checked against the hostnameB. func HostnamesMatch(hostnameA, hostnameB string) bool { - labelsA := strings.Split(hostnameA, ".") - labelsB := strings.Split(hostnameB, ".") + // the hostnames are in the form of "foo.bar.com"; split them + // in a slice of substrings + hostnameALabels := strings.Split(hostnameA, ".") + hostnameBLabels := strings.Split(hostnameB, ".") - var i, j int + var a, b int var wildcard bool - for i, j = 0, 0; i < len(labelsA) && j < len(labelsB); i, j = i+1, j+1 { + // iterate over the parts of both the hostnames + for a, b = 0, 0; a < len(hostnameALabels) && b < len(hostnameBLabels); a, b = a+1, b+1 { + var matchFound bool + + // if the current part of B is a wildcard, we need to find the first + // A part that matches with the following B part if wildcard { - for ; j < len(labelsB); j++ { - if labelsA[i] == labelsB[j] { + for ; b < len(hostnameBLabels); b++ { + if hostnameALabels[a] == hostnameBLabels[b] { + matchFound = true break } } - if j == len(labelsB) { - return false - } } - if labelsA[i] == "*" { + // if no match was found, the hostnames don't match + if wildcard && !matchFound { + return false + } + + // check if at least on of the current parts are a wildcard; if so, continue + if hostnameALabels[a] == "*" { wildcard = true - j-- continue } - + // reset the wildcard variables wildcard = false - if labelsA[i] != labelsB[j] { + // if the current a part is different from the b part, the hostnames are incompatible + if hostnameALabels[a] != hostnameBLabels[b] { return false } } - - return len(labelsA)-i == len(labelsB)-j + return len(hostnameBLabels)-b == len(hostnameALabels)-a } func routeMatchesListenerAllowedRoutes( @@ -892,3 +914,115 @@ func IsInvalidKindError(err error) bool { _, ok := err.(*InvalidKindError) return ok } + +// filterHostnames accepts a list of gateways and an HTTPRoute, and returns a copy of the HTTPRoute with only the hostnames that match the listener hostnames of the gateways. +// If the HTTPRoute hostnames do not intersect with the listener hostnames of the gateways, it returns an ErrNoMatchingListenerHostname error. +func filterHostnames(gateways []RouteParentRefContext, httpRoute *gatewayv1.HTTPRoute) (*gatewayv1.HTTPRoute, error) { + filteredHostnames := make([]gatewayv1.Hostname, 0) + + // If the HTTPRoute does not specify hostnames, we use the union of the listener hostnames of all supported gateways + // If any supported listener does not specify a hostname, the HTTPRoute hostnames remain empty to match any hostname + if len(httpRoute.Spec.Hostnames) == 0 { + hostnames, matchAnyHost := getUnionOfGatewayHostnames(gateways) + if matchAnyHost { + return httpRoute, nil + } + filteredHostnames = hostnames + } else { + // If the HTTPRoute specifies hostnames, we need to find the intersection with the gateway listener hostnames + for _, hostname := range httpRoute.Spec.Hostnames { + if hostnameMatching := getMinimumHostnameIntersection(gateways, hostname); hostnameMatching != "" { + filteredHostnames = append(filteredHostnames, hostnameMatching) + } + } + if len(filteredHostnames) == 0 { + return httpRoute, ErrNoMatchingListenerHostname + } + } + + log.Debugw("filtered hostnames", zap.Any("httpRouteHostnames", httpRoute.Spec.Hostnames), zap.Any("hostnames", filteredHostnames)) + httpRoute.Spec.Hostnames = filteredHostnames + return httpRoute, nil +} + +// getUnionOfGatewayHostnames returns the union of the hostnames specified in all supported gateways +// The second return value indicates whether any listener can match any hostname +func getUnionOfGatewayHostnames(gateways []RouteParentRefContext) ([]gatewayv1.Hostname, bool) { + hostnames := make([]gatewayv1.Hostname, 0) + + for _, gateway := range gateways { + if gateway.ListenerName != "" { + // If a listener name is specified, only check that listener + for _, listener := range gateway.Gateway.Spec.Listeners { + if string(listener.Name) == gateway.ListenerName { + // If a listener does not specify a hostname, it can match any hostname + if listener.Hostname == nil { + return nil, true + } + hostnames = append(hostnames, *listener.Hostname) + break + } + } + } else { + // Otherwise, check all listeners + for _, listener := range gateway.Gateway.Spec.Listeners { + // Only consider listeners that can effectively configure hostnames (HTTP, HTTPS, or TLS) + if isListenerHostnameEffective(listener) { + if listener.Hostname == nil { + return nil, true + } + hostnames = append(hostnames, *listener.Hostname) + } + } + } + } + + return hostnames, false +} + +// getMinimumHostnameIntersection returns the smallest intersection hostname +// - If the listener hostname is empty, return the HTTPRoute hostname +// - If the listener hostname is a wildcard of the HTTPRoute hostname, return the HTTPRoute hostname +// - If the HTTPRoute hostname is a wildcard of the listener hostname, return the listener hostname +// - If the HTTPRoute hostname and listener hostname are the same, return it +// - If none of the above, return an empty string +func getMinimumHostnameIntersection(gateways []RouteParentRefContext, hostname gatewayv1.Hostname) gatewayv1.Hostname { + for _, gateway := range gateways { + for _, listener := range gateway.Gateway.Spec.Listeners { + // If a listener name is specified, only check that listener + // If the listener name is not specified, check all listeners + if gateway.ListenerName == "" || gateway.ListenerName == string(listener.Name) { + if listener.Hostname == nil || *listener.Hostname == "" { + return hostname + } + if HostnamesMatch(string(*listener.Hostname), string(hostname)) { + return hostname + } + if HostnamesMatch(string(hostname), string(*listener.Hostname)) { + return *listener.Hostname + } + } + } + } + + return "" +} + +// isListenerHostnameEffective checks if a listener can specify a hostname to match the hostname in the request +// Basically, check if the listener uses HTTP, HTTPS, or TLS protocol +func isListenerHostnameEffective(listener gatewayv1.Listener) bool { + return listener.Protocol == gatewayv1.HTTPProtocolType || + listener.Protocol == gatewayv1.HTTPSProtocolType || + listener.Protocol == gatewayv1.TLSProtocolType +} + +func isRouteAccepted(gateways []RouteParentRefContext) bool { + for _, gateway := range gateways { + for _, condition := range gateway.Conditions { + if condition.Type == string(gatewayv1.RouteConditionAccepted) && condition.Status == metav1.ConditionTrue { + return true + } + } + } + return false +} diff --git a/test/conformance/conformance_test.go b/test/conformance/conformance_test.go index 704aebf47..b640768bb 100644 --- a/test/conformance/conformance_test.go +++ b/test/conformance/conformance_test.go @@ -34,11 +34,8 @@ var skippedTestsForTraditionalRoutes = []string{ tests.HTTPRouteReferenceGrant.ShortName, // TODO: HTTPRoute hostname intersection and listener hostname matching - tests.HTTPRouteHostnameIntersection.ShortName, - tests.HTTPRouteListenerHostnameMatching.ShortName, tests.GatewayInvalidTLSConfiguration.ShortName, - // tests.HTTPRouteInvalidBackendRefUnknownKind.ShortName, tests.HTTPRouteInvalidCrossNamespaceParentRef.ShortName, tests.HTTPRouteInvalidNonExistentBackendRef.ShortName, tests.HTTPRouteInvalidParentRefNotMatchingSectionName.ShortName,