Repository: nginx/nginx-gateway-fabric

Issue:
Set OverlappingTLSConfig condition on Listeners
**Is your enhancement request related to a problem? Please describe.**  
Currently, our controller does not set the OverlappingTLSConfig condition on Listeners when overlapping TLS configuration is detected (e.g., overlapping hostnames or certificates on the same port). This means users are not warned about potentially conflicting TLS configurations, which can lead to confusing runtime behavior, especially with HTTP connection coalescing.

**What would you like to be added**:  
Implement logic to detect overlapping TLS configuration (hostnames and, optionally, certificates) between Listeners on the same port, and set the OverlappingTLSConfig condition (with the appropriate reason: OverlappingHostnames or OverlappingCertificates) on all affected Listeners, as required by the Gateway API spec.

**Why this is needed**:  
This is required for Gateway API conformance and ensures users are made aware of configuration risks that can cause unexpected or insecure behavior. Even if runtime mitigations (such as toggles to disable SNI host validation) are present, the condition must still be set to surface the underlying configuration issue.

**Additional context**  
See Gateway API documentation for ListenerConditionOverlappingTLSConfig and associated reasons.  
This work is distinct from [the toggle implemented here](https://github.com/nginx/nginx-gateway-fabric/pull/3659) and should always be performed when overlap is detected, regardless of other settings.  
Reference:  
- https://gateway-api.sigs.k8s.io/references/spec/#gateway.networking.k8s.io/v1.ListenerConditionType

---

Discussion:

Solution patch:
diff --git a/internal/controller/state/conditions/conditions.go b/internal/controller/state/conditions/conditions.go
index bdebb678..a9d41884 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 d8524c93..13b6f4d8 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/status/prepare_requests.go b/internal/controller/status/prepare_requests.go
index d2f39a7a..3210e432 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 {



Test patch:
diff --git a/internal/controller/state/graph/gateway_listener_test.go b/internal/controller/state/graph/gateway_listener_test.go
index 0b75ddb7..5dd468c6 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 fe048193..4c7b82ef 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,
 						},
 					},