Repository: traefik/traefik (language: go)

Issue:
Allow multiple listeners on same port in Gateway API provider
### Welcome!

- [X] Yes, I've searched similar issues on [GitHub](https://github.com/traefik/traefik/issues) and didn't find any.
- [X] Yes, I've searched similar issues on the [Traefik community forum](https://community.traefik.io) and didn't find any.

### What did you expect to see?

Concern K8SGatewayAPI provider: 
Sould be possible to have multiple HTTP/HTTPS listeners on the same port (the goal of a reverse proxy :)
Now this is forbidden by this line : https://github.com/traefik/traefik/blob/1048348ae65497feb4a05e090f0ac910586c7996/pkg/provider/kubernetes/gateway/kubernetes.go#L343

Am I right ? 


Discussion / hints:
The config I would like to be working : 
```
  listeners:
  - allowedRoutes:
      namespaces:
        from: All
    hostname: hello.agepoly.ch
    name: hello.agepoly.ch
    port: 8443
    protocol: HTTPS
  - allowedRoutes:
      namespaces:
        from: All
    hostname: agevote-api.agepoly.ch
    name: agevote-api.agepoly.ch
    port: 8443
    protocol: HTTPS
```
Hello @TeoGoddet,

Thanks for your interest in Traefik,

> Am I right ?

You are right, as explained in the [specification](https://gateway-api.sigs.k8s.io/v1alpha2/references/spec/#gateway.networking.k8s.io/v1alpha2.Gateway), each listener in a Gateway must have a unique combination of Hostname, Port, and Protocol.

So the use case you are trying to achieve is valid. 



I would love this, too. Is there any chance i can help? I would need some pointers.

stack: traefik + gateway API + cert-manager + let's encrypt

**Problem:** 
There is no way (please prove me wrong!) to get multi-SAN certificates with ingress- or ingressroute-based traefik+cert-manager setups.

Since let's encrypt has request-/renew limits which are counted against the base domain you have to limit the number of sub-domains you use.

If you have 6 sub-domains "nr0 ... nr5.domain.com", you will break the renew limit of `5 renews per week per base domain` on every renew-cycle. This will get you a 7 day cool down period with let's encrypt without being able to renew one of the domains.

**Solution:**
If there are multiple listeners inside a gateway which differ in the hostname but have identical protocol, port, and gateway TLS certificateRefs,  cert-manager behaviour is to request a multi-SAN certificate for all of the hostnames.

Back to the 6 subdomains. The following block would be attached to all 6 listeners, e.g. `{ protocol: HTTPS, port: 443, hostname: nr<i>.domain.com }`. The gateway itself gets an annotation regarding the issuer/cluster-issuer to use. Done.

```yaml
    tls:
      mode: Terminate
      certificateRefs:
      - kind: Secret
        group: core
        name: my-secret-where-the-multi-san-cert-stuff-is-stored
        namespace: my-traefik-and-cert-manager-namespace
```
**Effect:**

The multi-SAN certificate helps you avoid the let's encrypt renew limits.


@burner-account maybe you can try a PR
the fix need to be done here 
https://github.com/traefik/traefik/blob/1048348ae65497feb4a05e090f0ac910586c7996/pkg/provider/kubernetes/gateway/kubernetes.go#L343

but did not have the time to investigate how much need to be changed
At first sight : 
```
if _, ok := allocatedPort[listener.Port]; ok {
			listenerStatuses[i].Conditions = append(listenerStatuses[i].Conditions, metav1.Condition{
				Type:               string(v1alpha2.ListenerConditionDetached),
				Status:             metav1.ConditionTrue,
				LastTransitionTime: metav1.Now(),
				Reason:             string(v1alpha2.ListenerReasonPortUnavailable),
				Message:            fmt.Sprintf("Port %d unavailable", listener.Port),
			})

			continue
		}
```
this should be raised only if : 
a TCP listener want an allocated port (any : HTTP,HTTPS,TCP)
an HTTP/HTTPS listener want a TCP allocated port (TCP)

this may need to be raised when a 
HTTP listener ask for an HTTPS already allocated and vice-versa
In theory, a small patch like this should be enough:

```go

shareableProtocols := []v1alpha2.ProtocolType{v1alpha2.HTTPSProtocolType}
isShareable := func(t v1alpha2.ProtocolType) bool {
    for _, lookup := range shareableProtocols {
        if lookup == t {
            return true
        }
    }
    return false
}

if proto, ok := allocatedPort[listener.Port]; ok {
     equal :=  listener.Protocol == proto
     if !equal || !isShareable(proto) {
        listenerStatuses[i].Conditions = append(listenerStatuses[i].Conditions, metav1.Condition{
	    Type:               string(v1alpha2.ListenerConditionDetached),
	    Status:             metav1.ConditionTrue,
	    LastTransitionTime: metav1.Now(),
	    Reason:             string(v1alpha2.ListenerReasonPortUnavailable),
	    Message:            fmt.Sprintf("Port %d unavailable", listener.Port),
        })
	continue
    }
}
```
This would retain the original behavior except HTTPS listeners.

Edit:
Made approach a bit more flexible.

If nobody refuses the updated approach from the previous post as "obvious BS" i could try to prepare a quick PR. 
Are there tests which ensure the current behavior and need to be looked at?

Fix patch:
diff --git a/pkg/provider/kubernetes/gateway/fixtures/mixed/with_multiple_protocol_using_same_port.yml b/pkg/provider/kubernetes/gateway/fixtures/mixed/with_multiple_listeners_using_same_hostname_port_protocol.yml
similarity index 76%
rename from pkg/provider/kubernetes/gateway/fixtures/mixed/with_multiple_protocol_using_same_port.yml
rename to pkg/provider/kubernetes/gateway/fixtures/mixed/with_multiple_listeners_using_same_hostname_port_protocol.yml
index 448dec2c76..dd3d32238f 100644
--- a/pkg/provider/kubernetes/gateway/fixtures/mixed/with_multiple_protocol_using_same_port.yml
+++ b/pkg/provider/kubernetes/gateway/fixtures/mixed/with_multiple_listeners_using_same_hostname_port_protocol.yml
@@ -21,12 +21,13 @@ spec:
 kind: Gateway
 apiVersion: gateway.networking.k8s.io/v1alpha2
 metadata:
-  name: my-gateway
+  name: my-gateway-http
   namespace: default
 spec:
   gatewayClassName: my-gateway-class
   listeners: # Use GatewayClass defaults for listener definition.
     - name: http1
+      hostname: foo.bar
       protocol: HTTP
       port: 9080
       allowedRoutes:
@@ -37,6 +38,7 @@ spec:
           from: Same
 
     - name: http2
+      hostname: foo.bar
       protocol: HTTP
       port: 9080
       allowedRoutes:
@@ -45,7 +47,26 @@ spec:
         namespaces:
           from: Same
 
-    - name: tcp
+---
+kind: Gateway
+apiVersion: gateway.networking.k8s.io/v1alpha2
+metadata:
+  name: my-gateway-tcp
+  namespace: default
+spec:
+  gatewayClassName: my-gateway-class
+  listeners: # Use GatewayClass defaults for listener definition.
+    - name: tcp1
+      hostname: foo.bar
+      protocol: TCP
+      port: 9000
+      allowedRoutes:
+        kinds:
+          - kind: TCPRoute
+        namespaces:
+          from: Same
+    - name: tcp2
+      hostname: foo.bar
       protocol: TCP
       port: 9000
       allowedRoutes:
@@ -54,7 +75,17 @@ spec:
         namespaces:
           from: Same
 
-    - name: tls
+---
+kind: Gateway
+apiVersion: gateway.networking.k8s.io/v1alpha2
+metadata:
+  name: my-gateway-tls
+  namespace: default
+spec:
+  gatewayClassName: my-gateway-class
+  listeners: # Use GatewayClass defaults for listener definition.
+    - name: tls1
+      hostname: foo.bar
       protocol: TLS
       port: 9000
       tls:
@@ -66,6 +97,7 @@ spec:
           from: Same
 
     - name: tls2
+      hostname: foo.bar
       protocol: TLS
       port: 9000
       tls:
diff --git a/pkg/provider/kubernetes/gateway/kubernetes.go b/pkg/provider/kubernetes/gateway/kubernetes.go
index 05698f5f9b..7b2bc5b727 100644
--- a/pkg/provider/kubernetes/gateway/kubernetes.go
+++ b/pkg/provider/kubernetes/gateway/kubernetes.go
@@ -317,7 +317,7 @@ func (p *Provider) createGatewayConf(ctx context.Context, client Client, gateway
 func (p *Provider) fillGatewayConf(ctx context.Context, client Client, gateway *v1alpha2.Gateway, conf *dynamic.Configuration, tlsConfigs map[string]*tls.CertAndStores) []v1alpha2.ListenerStatus {
 	logger := log.FromContext(ctx)
 	listenerStatuses := make([]v1alpha2.ListenerStatus, len(gateway.Spec.Listeners))
-	allocatedPort := map[v1alpha2.PortNumber]v1alpha2.ProtocolType{}
+	allocatedListeners := make(map[string]struct{})
 
 	for i, listener := range gateway.Spec.Listeners {
 		listenerStatuses[i] = v1alpha2.ListenerStatus{
@@ -340,19 +340,22 @@ func (p *Provider) fillGatewayConf(ctx context.Context, client Client, gateway *
 			continue
 		}
 
-		if _, ok := allocatedPort[listener.Port]; ok {
+		listenerKey := makeListenerKey(listener)
+
+		if _, ok := allocatedListeners[listenerKey]; ok {
 			listenerStatuses[i].Conditions = append(listenerStatuses[i].Conditions, metav1.Condition{
-				Type:               string(v1alpha2.ListenerConditionDetached),
+				Type:               string(v1alpha2.ListenerConditionConflicted),
 				Status:             metav1.ConditionTrue,
 				LastTransitionTime: metav1.Now(),
-				Reason:             string(v1alpha2.ListenerReasonPortUnavailable),
-				Message:            fmt.Sprintf("Port %d unavailable", listener.Port),
+				Reason:             "DuplicateListener",
+				Message:            "A listener with same protocol, port and hostname already exists",
 			})
 
 			continue
 		}
 
-		allocatedPort[listener.Port] = listener.Protocol
+		allocatedListeners[listenerKey] = struct{}{}
+
 		ep, err := p.entryPointName(listener.Port, listener.Protocol)
 		if err != nil {
 			// update "Detached" status with "PortUnavailable" reason
@@ -1700,3 +1703,13 @@ func isInternalService(ref v1alpha2.BackendRef) bool {
 		*ref.Group == traefikv1alpha1.GroupName &&
 		strings.HasSuffix(string(ref.Name), "@internal")
 }
+
+// makeListenerKey joins protocol, hostname, and port of a listener into a string key.
+func makeListenerKey(l v1alpha2.Listener) string {
+	var hostname v1alpha2.Hostname
+	if l.Hostname != nil {
+		hostname = *l.Hostname
+	}
+
+	return fmt.Sprintf("%s|%s|%d", l.Protocol, hostname, l.Port)
+}


Test patch:
diff --git a/pkg/provider/kubernetes/gateway/kubernetes_test.go b/pkg/provider/kubernetes/gateway/kubernetes_test.go
index 2fafd65bbf..a3ce54924e 100644
--- a/pkg/provider/kubernetes/gateway/kubernetes_test.go
+++ b/pkg/provider/kubernetes/gateway/kubernetes_test.go
@@ -3566,8 +3566,8 @@ func TestLoadMixedRoutes(t *testing.T) {
 			},
 		},
 		{
-			desc:  "Empty caused by mixed routes multiple protocol using same port",
-			paths: []string{"services.yml", "mixed/with_multiple_protocol_using_same_port.yml"},
+			desc:  "Empty caused by mixed routes with multiple listeners using same hostname, port and protocol",
+			paths: []string{"services.yml", "mixed/with_multiple_listeners_using_same_hostname_port_protocol.yml"},
 			entryPoints: map[string]Entrypoint{
 				"web": {Address: ":9080"},
 				"tcp": {Address: ":9000"},
@@ -4989,7 +4989,7 @@ func Test_shouldAttach(t *testing.T) {
 }
 
 func Test_matchingHostnames(t *testing.T) {
-	tests := []struct {
+	testCases := []struct {
 		desc      string
 		listener  v1alpha2.Listener
 		hostnames []v1alpha2.Hostname
@@ -5081,7 +5081,7 @@ func Test_matchingHostnames(t *testing.T) {
 		},
 	}
 
-	for _, test := range tests {
+	for _, test := range testCases {
 		test := test
 		t.Run(test.desc, func(t *testing.T) {
 			t.Parallel()
@@ -5093,7 +5093,7 @@ func Test_matchingHostnames(t *testing.T) {
 }
 
 func Test_getAllowedRoutes(t *testing.T) {
-	tests := []struct {
+	testCases := []struct {
 		desc                string
 		listener            v1alpha2.Listener
 		supportedRouteKinds []v1alpha2.RouteGroupKind
@@ -5193,7 +5193,7 @@ func Test_getAllowedRoutes(t *testing.T) {
 		},
 	}
 
-	for _, test := range tests {
+	for _, test := range testCases {
 		test := test
 		t.Run(test.desc, func(t *testing.T) {
 			t.Parallel()
@@ -5210,6 +5210,45 @@ func Test_getAllowedRoutes(t *testing.T) {
 	}
 }
 
+func Test_makeListenerKey(t *testing.T) {
+	testCases := []struct {
+		desc        string
+		listener    v1alpha2.Listener
+		expectedKey string
+	}{
+		{
+			desc:        "empty",
+			expectedKey: "||0",
+		},
+		{
+			desc: "listener with port, protocol and hostname",
+			listener: v1alpha2.Listener{
+				Port:     443,
+				Protocol: v1alpha2.HTTPSProtocolType,
+				Hostname: hostnamePtr("www.example.com"),
+			},
+			expectedKey: "HTTPS|www.example.com|443",
+		},
+		{
+			desc: "listener with port, protocol and nil hostname",
+			listener: v1alpha2.Listener{
+				Port:     443,
+				Protocol: v1alpha2.HTTPSProtocolType,
+			},
+			expectedKey: "HTTPS||443",
+		},
+	}
+
+	for _, test := range testCases {
+		test := test
+		t.Run(test.desc, func(t *testing.T) {
+			t.Parallel()
+
+			assert.Equal(t, test.expectedKey, makeListenerKey(test.listener))
+		})
+	}
+}
+
 func hostnamePtr(hostname v1alpha2.Hostname) *v1alpha2.Hostname {
 	return &hostname
 }