Repository: istio/istio (Go)

Issue:
NACK error on overlapping SNI matches at gateway
The following config can produce overlapping SNI matches, causing NACK:

```yaml
apiVersion: networking.istio.io/v1beta1
kind: Gateway
metadata:
  name: gw
  namespace: istio-system
spec:
  selector:
    istio: ingressgateway
  servers:
  - hosts:
    - example.com
    port:
      name: example
      number: 443
      protocol: TLS
    tls:
      mode: PASSTHROUGH
  - hosts:
    - '*'
    port:
      name: wildcard-tls
      number: 443
      protocol: HTTPS
    tls:
      mode: PASSTHROUGH
---
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: example
  namespace: istio-system
spec:
  gateways:
  - gw
  hosts:
  - example.com
  tls:
  - match:
    - sniHosts:
      - example.com
    route:
    - destination:
        host: example
```


Discussion:
good catch, have you investigated the cause?
The cause is wildcard gateway server is superset of specific one. IMO, we can improve gateway validation to deny such misconfig

1. same port with the same protocol, but different hosts(which has overlapping)
2. same port but different protocol?
We have some logic to detect this case already but it doesn't handle the duplication across server, only within 1 server. Each server ends up generating the same match since the VS matches both of them.

Validation is hard since I think the same could happen with two Gateways with 1 listener? 
Yup, validation can not prevent it being set in two `gateway`
/not stale



Patch:
diff --git a/pilot/pkg/model/gateway.go b/pilot/pkg/model/gateway.go
index 5b61097578be..a912891b3354 100644
--- a/pilot/pkg/model/gateway.go
+++ b/pilot/pkg/model/gateway.go
@@ -197,7 +197,7 @@ func MergeGateways(gateways []gatewayWithInstances, proxy *Proxy, ps *PushContex
 						tlsHostsByPort[resolvedPort] = map[string]string{}
 					}
 					if duplicateHosts := CheckDuplicates(s.Hosts, s.Bind, tlsHostsByPort[resolvedPort]); len(duplicateHosts) != 0 {
-						log.Debugf("skipping server on gateway %s, duplicate host names: %v", gatewayName, duplicateHosts)
+						log.Warnf("skipping server on gateway %s, duplicate host names: %v", gatewayName, duplicateHosts)
 						RecordRejectedConfig(gatewayName)
 						continue
 					}
diff --git a/pilot/pkg/networking/core/v1alpha3/gateway.go b/pilot/pkg/networking/core/v1alpha3/gateway.go
index 542286a79d81..01bb2779a577 100644
--- a/pilot/pkg/networking/core/v1alpha3/gateway.go
+++ b/pilot/pkg/networking/core/v1alpha3/gateway.go
@@ -68,6 +68,8 @@ func (configgen *ConfigGeneratorImpl) buildGatewayListeners(builder *ListenerBui
 	// Mutable objects keyed by listener name so that we can build listeners at the end.
 	mutableopts := make(map[string]mutableListenerOpts)
 	proxyConfig := builder.node.Metadata.ProxyConfigOrDefault(builder.push.Mesh.DefaultConfig)
+	// listener port -> host/bind
+	tlsHostsByPort := map[uint32]map[string]string{}
 	for _, port := range mergedGateway.ServerPorts {
 		// Skip ports we cannot bind to. Note that MergeGateways will already translate Service port to
 		// targetPort, which handles the common case of exposing ports like 80 and 443 but listening on
@@ -120,7 +122,7 @@ func (configgen *ConfigGeneratorImpl) buildGatewayListeners(builder *ListenerBui
 			var newFilterChains []istionetworking.FilterChain
 			switch transport {
 			case istionetworking.TransportProtocolTCP:
-				newFilterChains = configgen.buildGatewayTCPBasedFilterChains(builder, p, port, opts, serversForPort, proxyConfig, mergedGateway)
+				newFilterChains = configgen.buildGatewayTCPBasedFilterChains(builder, p, port, opts, serversForPort, proxyConfig, mergedGateway, tlsHostsByPort)
 			case istionetworking.TransportProtocolQUIC:
 				// Currently, we just assume that QUIC is HTTP/3 although that does not
 				// have to be the case (it is just the most common case now, in the future
@@ -187,6 +189,7 @@ func (configgen *ConfigGeneratorImpl) buildGatewayTCPBasedFilterChains(
 	serversForPort *model.MergedServers,
 	proxyConfig *meshconfig.ProxyConfig,
 	mergedGateway *model.MergedGateway,
+	tlsHostsByPort map[uint32]map[string]string,
 ) []istionetworking.FilterChain {
 	newFilterChains := make([]istionetworking.FilterChain, 0)
 	if p.IsHTTP() {
@@ -220,7 +223,7 @@ func (configgen *ConfigGeneratorImpl) buildGatewayTCPBasedFilterChains(
 			} else {
 				// This is the case of TCP or PASSTHROUGH.
 				tcpChainOpts := configgen.createGatewayTCPFilterChainOpts(builder.node, builder.push,
-					server, mergedGateway.GatewayNameForServer[server])
+					server, port.Number, mergedGateway.GatewayNameForServer[server], tlsHostsByPort)
 				tcpFilterChainOpts = append(tcpFilterChainOpts, tcpChainOpts...)
 				for i := 0; i < len(tcpChainOpts); i++ {
 					newFilterChains = append(newFilterChains, istionetworking.FilterChain{
@@ -699,8 +702,8 @@ func convertTLSProtocol(in networking.ServerTLSSettings_TLSProtocol) tls.TlsPara
 }
 
 func (configgen *ConfigGeneratorImpl) createGatewayTCPFilterChainOpts(
-	node *model.Proxy, push *model.PushContext, server *networking.Server,
-	gatewayName string,
+	node *model.Proxy, push *model.PushContext, server *networking.Server, listenerPort uint32,
+	gatewayName string, tlsHostsByPort map[uint32]map[string]string,
 ) []*filterChainOpts {
 	// We have a TCP/TLS server. This could be TLS termination (user specifies server.TLS with simple/mutual)
 	// or opaque TCP (server.TLS is nil). or it could be a TLS passthrough with SNI based routing.
@@ -733,7 +736,7 @@ func (configgen *ConfigGeneratorImpl) createGatewayTCPFilterChainOpts(
 		}
 	} else {
 		// Passthrough server.
-		return buildGatewayNetworkFiltersFromTLSRoutes(node, push, server, gatewayName)
+		return buildGatewayNetworkFiltersFromTLSRoutes(node, push, server, listenerPort, gatewayName, tlsHostsByPort)
 	}
 
 	return []*filterChainOpts{}
@@ -792,7 +795,7 @@ func buildGatewayNetworkFiltersFromTCPRoutes(node *model.Proxy, push *model.Push
 // It first obtains all virtual services bound to the set of Gateways for this workload, filters them by this
 // server's port and hostnames, and produces network filters for each destination from the filtered services
 func buildGatewayNetworkFiltersFromTLSRoutes(node *model.Proxy, push *model.PushContext, server *networking.Server,
-	gatewayName string,
+	listenerPort uint32, gatewayName string, tlsHostsByPort map[uint32]map[string]string,
 ) []*filterChainOpts {
 	port := &model.Port{
 		Name:     server.Port.Name,
@@ -819,8 +822,6 @@ func buildGatewayNetworkFiltersFromTLSRoutes(node *model.Proxy, push *model.Push
 			filterChains = append(filterChains, builtAutoPassthroughFilterChains(push, node, node.MergedGateway.TLSServerInfo[server].SNIHosts)...)
 		}
 	} else {
-		tlsSniHosts := map[string]string{} // sni host -> exists
-
 		virtualServices := push.VirtualServicesForGateway(node.ConfigNamespace, gatewayName)
 		for _, v := range virtualServices {
 			vsvc := v.Spec.(*networking.VirtualService)
@@ -845,8 +846,11 @@ func buildGatewayNetworkFiltersFromTLSRoutes(node *model.Proxy, push *model.Push
 						// Envoy will reject config that has multiple filter chain matches with the same matching rules
 						// To avoid this, we need to make sure we don't have duplicated SNI hosts, which will become
 						// SNI filter chain matches
-						if duplicateSniHosts := model.CheckDuplicates(match.SniHosts, server.Bind, tlsSniHosts); len(duplicateSniHosts) != 0 {
-							log.Debugf(
+						if tlsHostsByPort[listenerPort] == nil {
+							tlsHostsByPort[listenerPort] = make(map[string]string)
+						}
+						if duplicateSniHosts := model.CheckDuplicates(match.SniHosts, server.Bind, tlsHostsByPort[listenerPort]); len(duplicateSniHosts) != 0 {
+							log.Warnf(
 								"skipping VirtualService %s rule #%v on server port %d of gateway %s, duplicate SNI host names: %v",
 								v.Meta.Name, i, port.Port, gatewayName, duplicateSniHosts)
 							model.RecordRejectedConfig(gatewayName)


Test patch:
diff --git a/pilot/pkg/networking/core/v1alpha3/gateway_test.go b/pilot/pkg/networking/core/v1alpha3/gateway_test.go
index 234ec94a808f..fbbc6a01cb17 100644
--- a/pilot/pkg/networking/core/v1alpha3/gateway_test.go
+++ b/pilot/pkg/networking/core/v1alpha3/gateway_test.go
@@ -2853,6 +2853,174 @@ func TestBuildGatewayListenersFilters(t *testing.T) {
 	}
 }
 
+func TestGatewayFilterChainSNIOverlap(t *testing.T) {
+	cases := []struct {
+		name            string
+		gateways        []config.Config
+		virtualServices []config.Config
+	}{
+		{
+			name: "no sni overlap",
+			gateways: []config.Config{
+				{
+					Meta: config.Meta{Name: "gw", Namespace: "testns", GroupVersionKind: gvk.Gateway},
+					Spec: &networking.Gateway{
+						Servers: []*networking.Server{
+							{
+								Port:  &networking.Port{Name: "example", Number: 443, Protocol: "TLS"},
+								Hosts: []string{"example.com"},
+								Tls:   &networking.ServerTLSSettings{Mode: networking.ServerTLSSettings_PASSTHROUGH},
+							},
+						},
+					},
+				},
+			},
+			virtualServices: []config.Config{
+				{
+					Meta: config.Meta{Name: "example", Namespace: "testns", GroupVersionKind: gvk.VirtualService},
+					Spec: &networking.VirtualService{
+						Gateways: []string{"testns/gw"},
+						Hosts:    []string{"example.com"},
+						Tls: []*networking.TLSRoute{
+							{
+								Match: []*networking.TLSMatchAttributes{
+									{
+										SniHosts: []string{"example.com"},
+									},
+								},
+								Route: []*networking.RouteDestination{
+									{
+										Destination: &networking.Destination{
+											Host: "example",
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "sni overlap in one gateway",
+			gateways: []config.Config{
+				{
+					Meta: config.Meta{Name: "gw", Namespace: "testns", GroupVersionKind: gvk.Gateway},
+					Spec: &networking.Gateway{
+						Servers: []*networking.Server{
+							{
+								Port:  &networking.Port{Name: "example", Number: 443, Protocol: "TLS"},
+								Hosts: []string{"example.com"},
+								Tls:   &networking.ServerTLSSettings{Mode: networking.ServerTLSSettings_PASSTHROUGH},
+							},
+							{
+								Port:  &networking.Port{Name: "wildcard-tls", Number: 443, Protocol: "HTTPS"},
+								Hosts: []string{"*"},
+								Tls:   &networking.ServerTLSSettings{Mode: networking.ServerTLSSettings_PASSTHROUGH},
+							},
+						},
+					},
+				},
+			},
+			virtualServices: []config.Config{
+				{
+					Meta: config.Meta{Name: "example", Namespace: "testns", GroupVersionKind: gvk.VirtualService},
+					Spec: &networking.VirtualService{
+						Gateways: []string{"testns/gw"},
+						Hosts:    []string{"example.com"},
+						Tls: []*networking.TLSRoute{
+							{
+								Match: []*networking.TLSMatchAttributes{
+									{
+										SniHosts: []string{"example.com"},
+									},
+								},
+								Route: []*networking.RouteDestination{
+									{
+										Destination: &networking.Destination{
+											Host: "example",
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "sni overlap in two gateways",
+			gateways: []config.Config{
+				{
+					Meta: config.Meta{Name: "gw1", Namespace: "testns", GroupVersionKind: gvk.Gateway},
+					Spec: &networking.Gateway{
+						Servers: []*networking.Server{
+							{
+								Port:  &networking.Port{Name: "example", Number: 443, Protocol: "TLS"},
+								Hosts: []string{"example.com"},
+								Tls:   &networking.ServerTLSSettings{Mode: networking.ServerTLSSettings_PASSTHROUGH},
+							},
+						},
+					},
+				},
+				{
+					Meta: config.Meta{Name: "gw2", Namespace: "testns", GroupVersionKind: gvk.Gateway},
+					Spec: &networking.Gateway{
+						Servers: []*networking.Server{
+							{
+								Port:  &networking.Port{Name: "wildcard-tls", Number: 443, Protocol: "HTTPS"},
+								Hosts: []string{"*"},
+								Tls:   &networking.ServerTLSSettings{Mode: networking.ServerTLSSettings_PASSTHROUGH},
+							},
+						},
+					},
+				},
+			},
+			virtualServices: []config.Config{
+				{
+					Meta: config.Meta{Name: "example", Namespace: "testns", GroupVersionKind: gvk.VirtualService},
+					Spec: &networking.VirtualService{
+						Gateways: []string{"testns/gw2"},
+						Hosts:    []string{"example.com"},
+						Tls: []*networking.TLSRoute{
+							{
+								Match: []*networking.TLSMatchAttributes{
+									{
+										SniHosts: []string{"example.com"},
+									},
+								},
+								Route: []*networking.RouteDestination{
+									{
+										Destination: &networking.Destination{
+											Host: "example",
+										},
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tt := range cases {
+		t.Run(tt.name, func(t *testing.T) {
+			configs := make([]config.Config, 0)
+			configs = append(configs, tt.gateways...)
+			configs = append(configs, tt.virtualServices...)
+			cg := NewConfigGenTest(t, TestOptions{
+				Configs: configs,
+			})
+			proxy := cg.SetupProxy(&proxyGateway)
+			proxy.Metadata = &proxyGatewayMetadata
+
+			builder := cg.ConfigGen.buildGatewayListeners(NewListenerBuilder(proxy, cg.PushContext()))
+			xdstest.ValidateListeners(t, builder.gatewayListeners)
+		})
+	}
+}
+
 func TestGatewayHCMInternalAddressConfig(t *testing.T) {
 	cg := NewConfigGenTest(t, TestOptions{})
 	proxy := &pilot_model.Proxy{