diff --git a/internal/controller/state/conditions/conditions.go b/internal/controller/state/conditions/conditions.go index bdebb678da..a9d418847e 100644 --- a/internal/controller/state/conditions/conditions.go +++ b/internal/controller/state/conditions/conditions.go @@ -29,6 +29,11 @@ const ( ListenerMessageFailedNginxReload = "The Listener is not programmed due to a failure to " + "reload nginx with the configuration" + // ListenerMessageOverlappingHostnames is a message used with the "OverlappingTLSConfig" condition when the + // condition is true due to overlapping hostnames. + ListenerMessageOverlappingHostnames = "Listener hostname overlaps with hostname(s) of other Listener(s) " + + "on the same port" + // RouteReasonBackendRefUnsupportedValue is used with the "ResolvedRefs" condition when one of the // Route rules has a backendRef with an unsupported value. RouteReasonBackendRefUnsupportedValue v1.RouteConditionReason = "UnsupportedValue" @@ -501,13 +506,32 @@ func NewRouteResolvedRefsInvalidFilter(msg string) Condition { } // NewDefaultListenerConditions returns the default Conditions that must be present in the status of a Listener. -func NewDefaultListenerConditions() []Condition { - return []Condition{ +// If existingConditions contains conflict-related conditions (like OverlappingTLSConfig or Conflicted), +// the NoConflicts condition is excluded to avoid conflicting condition states. +func NewDefaultListenerConditions(existingConditions []Condition) []Condition { + defaultConds := []Condition{ NewListenerAccepted(), NewListenerProgrammed(), NewListenerResolvedRefs(), - NewListenerNoConflicts(), } + + // Only add NoConflicts condition if there are no existing conflict-related conditions + if !hasConflictConditions(existingConditions) { + defaultConds = append(defaultConds, NewListenerNoConflicts()) + } + + return defaultConds +} + +// hasConflictConditions checks if the listener has any conflict-related conditions. +func hasConflictConditions(conditions []Condition) bool { + for _, cond := range conditions { + if cond.Type == string(v1.ListenerConditionConflicted) || + cond.Type == string(v1.ListenerConditionOverlappingTLSConfig) { + return true + } + } + return false } // NewListenerAccepted returns a Condition that indicates that the Listener is accepted. @@ -681,6 +705,17 @@ func NewListenerRefNotPermitted(msg string) []Condition { } } +// NewListenerOverlappingTLSConfig returns a Condition that indicates overlapping TLS configuration +// between Listeners on the same port. +func NewListenerOverlappingTLSConfig(reason v1.ListenerConditionReason, msg string) Condition { + return Condition{ + Type: string(v1.ListenerConditionOverlappingTLSConfig), + Status: metav1.ConditionTrue, + Reason: string(reason), + Message: msg, + } +} + // NewGatewayClassResolvedRefs returns a Condition that indicates that the parametersRef // on the GatewayClass is resolved. func NewGatewayClassResolvedRefs() Condition { diff --git a/internal/controller/state/graph/gateway_listener.go b/internal/controller/state/graph/gateway_listener.go index d8524c93a8..13b6f4d880 100644 --- a/internal/controller/state/graph/gateway_listener.go +++ b/internal/controller/state/graph/gateway_listener.go @@ -89,6 +89,7 @@ func newListenerConfiguratorFactory( protectedPorts ProtectedPorts, ) *listenerConfiguratorFactory { sharedPortConflictResolver := createPortConflictResolver() + sharedOverlappingTLSConfigResolver := createOverlappingTLSConfigResolver() return &listenerConfiguratorFactory{ unsupportedProtocol: &listenerConfigurator{ @@ -123,6 +124,7 @@ func newListenerConfiguratorFactory( }, conflictResolvers: []listenerConflictResolver{ sharedPortConflictResolver, + sharedOverlappingTLSConfigResolver, }, externalReferenceResolvers: []listenerExternalReferenceResolver{ createExternalReferencesForTLSSecretsResolver(gw.Namespace, secretResolver, refGrantResolver), @@ -137,6 +139,7 @@ func newListenerConfiguratorFactory( }, conflictResolvers: []listenerConflictResolver{ sharedPortConflictResolver, + sharedOverlappingTLSConfigResolver, }, externalReferenceResolvers: []listenerExternalReferenceResolver{}, }, @@ -591,3 +594,38 @@ func haveOverlap(hostname1, hostname2 *v1.Hostname) bool { } return matchesWildcard(h1, h2) } + +func createOverlappingTLSConfigResolver() listenerConflictResolver { + listenersByPort := make(map[v1.PortNumber][]*Listener) + + return func(l *Listener) { + port := l.Source.Port + + // Only check TLS-enabled listeners (HTTPS/TLS) + if l.Source.Protocol != v1.HTTPSProtocolType && l.Source.Protocol != v1.TLSProtocolType { + return + } + + // Check for overlaps with existing listeners on this port + for _, existingListener := range listenersByPort[port] { + // Only check against other TLS-enabled listeners + if existingListener.Source.Protocol != v1.HTTPSProtocolType && + existingListener.Source.Protocol != v1.TLSProtocolType { + continue + } + + // Check for hostname overlap + if haveOverlap(l.Source.Hostname, existingListener.Source.Hostname) { + // Set condition on both listeners + cond := conditions.NewListenerOverlappingTLSConfig( + v1.ListenerReasonOverlappingHostnames, + conditions.ListenerMessageOverlappingHostnames, + ) + l.Conditions = append(l.Conditions, cond) + existingListener.Conditions = append(existingListener.Conditions, cond) + } + } + + listenersByPort[port] = append(listenersByPort[port], l) + } +} diff --git a/internal/controller/state/graph/gateway_listener_test.go b/internal/controller/state/graph/gateway_listener_test.go index 0b75ddb7c0..5dd468c669 100644 --- a/internal/controller/state/graph/gateway_listener_test.go +++ b/internal/controller/state/graph/gateway_listener_test.go @@ -637,3 +637,311 @@ func TestValidateTLSFieldOnTLSListener(t *testing.T) { }) } } + +func TestOverlappingTLSConfigCondition(t *testing.T) { + t.Parallel() + + protectedPorts := ProtectedPorts{9113: "MetricsPort"} + + tests := []struct { + gateway *v1.Gateway + name string + conditionReason v1.ListenerConditionReason + expectedCondition bool + }{ + { + name: "overlapping hostnames on same port", + gateway: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gateway", + Namespace: "test-ns", + }, + Spec: v1.GatewaySpec{ + Listeners: []v1.Listener{ + { + Name: "listener1", + Port: 443, + Protocol: v1.HTTPSProtocolType, + Hostname: helpers.GetPointer[v1.Hostname]("*.example.com"), + TLS: &v1.GatewayTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + CertificateRefs: []v1.SecretObjectReference{ + {Name: "secret1"}, + }, + }, + }, + { + Name: "listener2", + Port: 443, + Protocol: v1.HTTPSProtocolType, + Hostname: helpers.GetPointer[v1.Hostname]("app.example.com"), + TLS: &v1.GatewayTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + CertificateRefs: []v1.SecretObjectReference{ + {Name: "secret2"}, + }, + }, + }, + }, + }, + }, + expectedCondition: true, + conditionReason: v1.ListenerReasonOverlappingHostnames, + }, + { + name: "no overlap - different ports", + gateway: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gateway", + Namespace: "test-ns", + }, + Spec: v1.GatewaySpec{ + Listeners: []v1.Listener{ + { + Name: "listener1", + Port: 443, + Protocol: v1.HTTPSProtocolType, + Hostname: helpers.GetPointer[v1.Hostname]("*.example.com"), + TLS: &v1.GatewayTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + CertificateRefs: []v1.SecretObjectReference{ + {Name: "secret1"}, + }, + }, + }, + { + Name: "listener2", + Port: 8443, + Protocol: v1.HTTPSProtocolType, + Hostname: helpers.GetPointer[v1.Hostname]("app.example.com"), + TLS: &v1.GatewayTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + CertificateRefs: []v1.SecretObjectReference{ + {Name: "secret2"}, + }, + }, + }, + }, + }, + }, + expectedCondition: false, + }, + { + name: "no overlap - different hostnames same port", + gateway: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gateway", + Namespace: "test-ns", + }, + Spec: v1.GatewaySpec{ + Listeners: []v1.Listener{ + { + Name: "listener1", + Port: 443, + Protocol: v1.HTTPSProtocolType, + Hostname: helpers.GetPointer[v1.Hostname]("app.example.com"), + TLS: &v1.GatewayTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + CertificateRefs: []v1.SecretObjectReference{ + {Name: "secret1"}, + }, + }, + }, + { + Name: "listener2", + Port: 443, + Protocol: v1.HTTPSProtocolType, + Hostname: helpers.GetPointer[v1.Hostname]("cafe.example.org"), + TLS: &v1.GatewayTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + CertificateRefs: []v1.SecretObjectReference{ + {Name: "secret2"}, + }, + }, + }, + }, + }, + }, + expectedCondition: false, + }, + { + name: "overlap between HTTPS and TLS listeners", + gateway: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gateway", + Namespace: "test-ns", + }, + Spec: v1.GatewaySpec{ + Listeners: []v1.Listener{ + { + Name: "listener1", + Port: 443, + Protocol: v1.HTTPSProtocolType, + Hostname: helpers.GetPointer[v1.Hostname]("*.example.com"), + TLS: &v1.GatewayTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + CertificateRefs: []v1.SecretObjectReference{ + {Name: "secret1"}, + }, + }, + }, + { + Name: "listener2", + Port: 443, + Protocol: v1.TLSProtocolType, + Hostname: helpers.GetPointer[v1.Hostname]("app.example.com"), + TLS: &v1.GatewayTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModePassthrough), + }, + }, + }, + }, + }, + expectedCondition: true, + conditionReason: v1.ListenerReasonOverlappingHostnames, + }, + { + name: "overlap with nil hostnames", + gateway: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gateway", + Namespace: "test-ns", + }, + Spec: v1.GatewaySpec{ + Listeners: []v1.Listener{ + { + Name: "listener1", + Port: 443, + Protocol: v1.HTTPSProtocolType, + Hostname: nil, // nil hostname matches everything + TLS: &v1.GatewayTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + CertificateRefs: []v1.SecretObjectReference{ + {Name: "secret1"}, + }, + }, + }, + { + Name: "listener2", + Port: 443, + Protocol: v1.HTTPSProtocolType, + Hostname: helpers.GetPointer[v1.Hostname]("app.example.com"), + TLS: &v1.GatewayTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + CertificateRefs: []v1.SecretObjectReference{ + {Name: "secret2"}, + }, + }, + }, + }, + }, + }, + expectedCondition: true, + conditionReason: v1.ListenerReasonOverlappingHostnames, + }, + { + name: "no overlap - HTTP listener excluded", + gateway: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gateway", + Namespace: "test-ns", + }, + Spec: v1.GatewaySpec{ + Listeners: []v1.Listener{ + { + Name: "listener1", + Port: 80, + Protocol: v1.HTTPProtocolType, + Hostname: helpers.GetPointer[v1.Hostname]("*.example.com"), + }, + { + Name: "listener2", + Port: 80, + Protocol: v1.HTTPProtocolType, + Hostname: helpers.GetPointer[v1.Hostname]("app.example.com"), + }, + }, + }, + }, + expectedCondition: false, + }, + { + name: "no overlap - HTTP and HTTPS listeners with same hostname and port", + gateway: &v1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gateway", + Namespace: "test-ns", + }, + Spec: v1.GatewaySpec{ + Listeners: []v1.Listener{ + { + Name: "listener1", + Port: 80, + Protocol: v1.HTTPProtocolType, + Hostname: helpers.GetPointer[v1.Hostname]("app.example.com"), + }, + { + Name: "listener2", + Port: 80, + Protocol: v1.HTTPSProtocolType, + Hostname: helpers.GetPointer[v1.Hostname]("app.example.com"), + TLS: &v1.GatewayTLSConfig{ + Mode: helpers.GetPointer(v1.TLSModeTerminate), + CertificateRefs: []v1.SecretObjectReference{ + {Name: "secret1"}, + }, + }, + }, + }, + }, + }, + expectedCondition: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + g := NewWithT(t) + + // Create mock resolvers + secretResolver := newSecretResolver(nil) + refGrantResolver := newReferenceGrantResolver(nil) + + // Build listeners + listeners := buildListeners(test.gateway, secretResolver, refGrantResolver, protectedPorts) + + if test.expectedCondition { + // Check that the expected listeners have the OverlappingTLSConfig condition + listenersWithCondition := 0 + for _, listener := range listeners { + found := false + for _, cond := range listener.Conditions { + if cond.Type == string(v1.ListenerConditionOverlappingTLSConfig) { + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + g.Expect(cond.Reason).To(Equal(string(test.conditionReason))) + found = true + break + } + } + if found { + listenersWithCondition++ + } + } + // At least 2 listeners should have the condition when there's overlap + g.Expect(listenersWithCondition).To( + BeNumerically(">=", 2), + "at least 2 listeners should have OverlappingTLSConfig condition", + ) + } else { + // No listener should have the OverlappingTLSConfig condition + for i, listener := range listeners { + for _, cond := range listener.Conditions { + g.Expect(cond.Type).ToNot(Equal(string(v1.ListenerConditionOverlappingTLSConfig)), + "listener %d should not have OverlappingTLSConfig condition", i) + } + } + } + }) + } +} diff --git a/internal/controller/state/graph/gateway_test.go b/internal/controller/state/graph/gateway_test.go index fe048193fd..4c7b82ef4c 100644 --- a/internal/controller/state/graph/gateway_test.go +++ b/internal/controller/state/graph/gateway_test.go @@ -1250,7 +1250,13 @@ func TestBuildGateway(t *testing.T) { Attachable: true, Routes: map[RouteKey]*L7Route{}, L4Routes: map[L4RouteKey]*L4Route{}, - Conditions: conditions.NewListenerHostnameConflict(conflict443HostnameMsg), + Conditions: append( + conditions.NewListenerHostnameConflict(conflict443HostnameMsg), + conditions.NewListenerOverlappingTLSConfig( + v1.ListenerReasonOverlappingHostnames, + "Listener hostname overlaps with hostname(s) of other Listener(s) on the same port", + ), + ), SupportedKinds: []v1.RouteGroupKind{ {Kind: kinds.TLSRoute, Group: helpers.GetPointer[v1.Group](v1.GroupName)}, }, @@ -1264,7 +1270,13 @@ func TestBuildGateway(t *testing.T) { ResolvedSecret: helpers.GetPointer(client.ObjectKeyFromObject(secretSameNs)), Routes: map[RouteKey]*L7Route{}, L4Routes: map[L4RouteKey]*L4Route{}, - Conditions: conditions.NewListenerHostnameConflict(conflict443HostnameMsg), + Conditions: append( + conditions.NewListenerHostnameConflict(conflict443HostnameMsg), + conditions.NewListenerOverlappingTLSConfig( + v1.ListenerReasonOverlappingHostnames, + "Listener hostname overlaps with hostname(s) of other Listener(s) on the same port", + ), + ), SupportedKinds: supportedKindsForListeners, }, }, diff --git a/internal/controller/status/prepare_requests.go b/internal/controller/status/prepare_requests.go index d2f39a7a3a..3210e432ec 100644 --- a/internal/controller/status/prepare_requests.go +++ b/internal/controller/status/prepare_requests.go @@ -283,13 +283,11 @@ func prepareGatewayRequest( validListenerCount := 0 for _, l := range gateway.Listeners { - var conds []conditions.Condition + conds := l.Conditions if l.Valid { - conds = conditions.NewDefaultListenerConditions() + conds = append(conds, conditions.NewDefaultListenerConditions(conds)...) validListenerCount++ - } else { - conds = l.Conditions } if nginxReloadRes.Error != nil {