Repository: envoyproxy/gateway (2026-03-13T12:07:26Z)

Issue:
Gateway generates invalid Envoy config with multiple EmptyCluster Routes
*Description*:

https://github.com/envoyproxy/gateway/blob/fba4f46ddf4b342e31379d1c493cb734ff2ddb9a/internal/xds/translator/translator.go#L834-L836 adds a dummy `EmptyCluster` TCPRoute for TCP listeners (or TLS listeners in Passthrough mode) with no routes. But if there is more than one such listener, multiple of these identical routes result in invalid Envoy configuration:
```
[2025-12-22 17:34:42.584][1][warning][config] [source/extensions/config_subscription/grpc/delta_subscription_state.cc:296] delta config for type.googleapis.com/envoy.config.listener.v3.Listener rejected: Error adding/updating listener(s) xxx: error adding listener '0.0.0.0:10443': filter chain 'EmptyCluster' has the same matching rules defined as 'EmptyCluster'. duplicate matcher is: {}
```

In our specific case, we had certmanager annotations on a Gateway with only Passthrough TLS listeners, so there was nothing that actually needed a certificate. But a certmanager bug https://github.com/cert-manager/cert-manager/issues/6985 (fixed in 1.18) caused it to reject the listener instead of just ignoring it. But this seems like a generic issue that could be triggered separately from certmanager usage.


*Repro steps*:

Create multiple TCP or TLS/Passthrough listeners without any attached Routes.


*Environment*:

Gateway 1.6.1 and 1.5.6




Discussion:
cc @zhaohuabing 
Hi Maintainers,

I’ve been looking into this issue and spent some time understanding how TCP / TLS passthrough listeners are translated when no routes are attached.

From what I see, the problem comes from adding identical dummy `EmptyCluster` TCPRoutes per listener, which works for a single listener but results in invalid Envoy config once multiple passthrough listeners exist with zero routes.

I’m currently digging into the translator logic around this path and exploring ways to avoid generating duplicate dummy routes (either by deduplicating or scoping them correctly per listener).

If this approach sounds reasonable, I’d be happy to take this up and propose a fix. Please let me know if I can get assigned. Thanks!
This issue has been automatically marked as stale because it has not had activity in the last 30 days.



Patch:
diff --git a/internal/xds/translator/translator.go b/internal/xds/translator/translator.go
index f5c6047a06..cc96a7a420 100644
--- a/internal/xds/translator/translator.go
+++ b/internal/xds/translator/translator.go
@@ -761,6 +761,8 @@ func (t *Translator) processTCPListenerXdsTranslation(
 	// The XDS translation is done in a best-effort manner, so we collect all
 	// errors and return them at the end.
 	var errs, err error
+	var sharedEmptyTCPRoute *ir.TCPRoute
+	emptyFilterChainAdded := make(map[string]bool)
 
 	for _, tcpListener := range tcpListeners {
 		// Search for an existing listener, if it does not exist, create one.
@@ -851,28 +853,37 @@ func (t *Translator) processTCPListenerXdsTranslation(
 		// If there are no routes, add a route without a destination to the listener to create a filter chain
 		// This is needed because Envoy requires a filter chain to be present in the listener, otherwise it will reject the listener and report a warning
 		if len(tcpListener.Routes) == 0 {
+			// Reuse shared EmptyCluster across all listeners without routes
 			if findXdsCluster(tCtx, emptyClusterName) == nil {
 				if err := tCtx.AddXdsResource(resourcev3.ClusterType, emptyRouteCluster); err != nil {
 					errs = errors.Join(errs, err)
 				}
 			}
 
-			emptyRoute := &ir.TCPRoute{
-				Name: emptyClusterName,
-				Destination: &ir.RouteDestination{
+			if sharedEmptyTCPRoute == nil {
+				sharedEmptyTCPRoute = &ir.TCPRoute{
 					Name: emptyClusterName,
-				},
+					Destination: &ir.RouteDestination{
+						Name: emptyClusterName,
+					},
+				}
 			}
-			if err := t.addXdsTCPFilterChain(
-				xdsListener,
-				emptyRoute,
-				emptyClusterName,
-				accesslog,
-				tcpListener.Timeout,
-				tcpListener.Connection,
-				tcpListener.TLS,
-			); err != nil {
-				errs = errors.Join(errs, err)
+
+			// Only add the filter chain once per xDS listener; multiple IR listeners may share
+			// the same address/port and map to the same xDS listener.
+			if !emptyFilterChainAdded[xdsListener.Name] {
+				if err := t.addXdsTCPFilterChain(
+					xdsListener,
+					sharedEmptyTCPRoute,
+					emptyClusterName,
+					accesslog,
+					tcpListener.Timeout,
+					tcpListener.Connection,
+					tcpListener.TLS,
+				); err != nil {
+					errs = errors.Join(errs, err)
+				}
+				emptyFilterChainAdded[xdsListener.Name] = true
 			}
 		}
 	}


Test patch:
diff --git a/internal/xds/translator/testdata/in/xds-ir/tcp-multiple-tls-passthrough-no-routes.yaml b/internal/xds/translator/testdata/in/xds-ir/tcp-multiple-tls-passthrough-no-routes.yaml
new file mode 100644
index 0000000000..e4a08bf14a
--- /dev/null
+++ b/internal/xds/translator/testdata/in/xds-ir/tcp-multiple-tls-passthrough-no-routes.yaml
@@ -0,0 +1,16 @@
+tcp:
+- name: tls-passthrough-1
+  address: 0.0.0.0
+  port: 10443
+  tls: {}
+  routes: []
+- name: tls-passthrough-2
+  address: 0.0.0.0
+  port: 10443
+  tls: {}
+  routes: []
+- name: tls-passthrough-3
+  address: 0.0.0.0
+  port: 10443
+  tls: {}
+  routes: []
diff --git a/internal/xds/translator/testdata/out/xds-ir/tcp-multiple-tls-passthrough-no-routes.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/tcp-multiple-tls-passthrough-no-routes.clusters.yaml
new file mode 100644
index 0000000000..0764d46f0e
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/tcp-multiple-tls-passthrough-no-routes.clusters.yaml
@@ -0,0 +1,2 @@
+- name: EmptyCluster
+  type: STATIC
diff --git a/internal/xds/translator/testdata/out/xds-ir/tcp-multiple-tls-passthrough-no-routes.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/tcp-multiple-tls-passthrough-no-routes.endpoints.yaml
new file mode 100644
index 0000000000..fe51488c70
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/tcp-multiple-tls-passthrough-no-routes.endpoints.yaml
@@ -0,0 +1,1 @@
+[]
diff --git a/internal/xds/translator/testdata/out/xds-ir/tcp-multiple-tls-passthrough-no-routes.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/tcp-multiple-tls-passthrough-no-routes.listeners.yaml
new file mode 100644
index 0000000000..dab0ffb896
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/tcp-multiple-tls-passthrough-no-routes.listeners.yaml
@@ -0,0 +1,15 @@
+- address:
+    socketAddress:
+      address: 0.0.0.0
+      portValue: 10443
+  filterChains:
+  - filters:
+    - name: envoy.filters.network.tcp_proxy
+      typedConfig:
+        '@type': type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy
+        cluster: EmptyCluster
+        statPrefix: tcp-10443
+    name: EmptyCluster
+  maxConnectionsToAcceptPerSocketEvent: 1
+  name: tls-passthrough-1
+  perConnectionBufferLimitBytes: 32768
diff --git a/internal/xds/translator/testdata/out/xds-ir/tcp-multiple-tls-passthrough-no-routes.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/tcp-multiple-tls-passthrough-no-routes.routes.yaml
new file mode 100644
index 0000000000..fe51488c70
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/tcp-multiple-tls-passthrough-no-routes.routes.yaml
@@ -0,0 +1,1 @@
+[]