This is an automated email from the ASF dual-hosted git repository.

AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-ingress-controller.git


The following commit(s) were added to refs/heads/master by this push:
     new 5a25a3bf feat: serve TLSRoute in Passthrough mode (#2882)
5a25a3bf is described below

commit 5a25a3bf34939f815b7b076258997a6cce659398
Author: AlinsRan <[email protected]>
AuthorDate: Tue Oct 13 15:46:41 2026 +0800

    feat: serve TLSRoute in Passthrough mode (#2882)
---
 .github/workflows/apisix-e2e-test.yml      |   2 +-
 Makefile                                   |   8 +-
 api/adc/types.go                           |  13 +-
 api/adc/zz_generated.deepcopy.go           |  10 ++
 config/manager/kustomization.yaml          |   2 +-
 docs/en/latest/concepts/gateway-api.md     |   2 +-
 internal/adc/translator/l4route_test.go    |  15 +-
 internal/adc/translator/tlsroute.go        | 127 +++++++++++--
 internal/adc/translator/tlsroute_test.go   | 275 +++++++++++++++++++++++++++++
 internal/controller/tlsroute_controller.go |  23 ++-
 internal/controller/utils.go               | 185 +++++++++----------
 internal/controller/utils/hostname.go      | 130 ++++++++++++++
 internal/controller/utils_hostname_test.go | 216 +++++++++++++++++++++-
 test/conformance/conformance_test.go       |  24 +--
 test/conformance/suite_test.go             |   7 +
 test/e2e/framework/manifests/apisix.yaml   |  18 +-
 test/e2e/gatewayapi/tlsroute.go            |  85 +++++++++
 test/e2e/gatewayapi/udproute.go            |  13 +-
 test/e2e/scaffold/apisix_deployer.go       |  31 +++-
 test/e2e/scaffold/apisix_prewarm.go        |  15 +-
 test/e2e/scaffold/deployer.go              |   9 +-
 test/e2e/scaffold/scaffold.go              | 183 +++++++++++++++++++
 22 files changed, 1214 insertions(+), 179 deletions(-)

diff --git a/.github/workflows/apisix-e2e-test.yml 
b/.github/workflows/apisix-e2e-test.yml
index ea669f54..f94dd442 100644
--- a/.github/workflows/apisix-e2e-test.yml
+++ b/.github/workflows/apisix-e2e-test.yml
@@ -30,7 +30,7 @@ concurrency:
   cancel-in-progress: true
 
 env:
-  ADC_RUST_VERSION: "0.30.5"
+  ADC_RUST_VERSION: "0.31.0"
 
 jobs:
   e2e-test:
diff --git a/Makefile b/Makefile
index 2736bee9..0b02cf50 100644
--- a/Makefile
+++ b/Makefile
@@ -29,7 +29,7 @@ KIND_NAME ?= apisix-ingress-cluster
 CLOUD_PROVIDER_KIND_VERSION ?= v0.6.0
 CLOUD_PROVIDER_KIND_PID ?= /tmp/cloud-provider-kind.pid
 
-ADC_VERSION ?= 0.30.5
+ADC_VERSION ?= 0.31.0
 
 DIR := $(shell pwd)
 
@@ -71,8 +71,10 @@ CONFORMANCE_CHANNEL ?= experimental
 # report's Reproduce section.
 CONFORMANCE_MODE ?= default
 # The data plane a release report is produced against. apisix:dev is a floating
-# tag, so a report meant to be reproducible has to name a released one.
-CONFORMANCE_DATAPLANE_VERSION ?= 3.17.0-debian
+# tag, so a report meant to be reproducible has to name a released one. 3.19.0
+# is the first release carrying apache/apisix#13912 (stream_proxy
+# tls_passthrough), which the TLSRoute Passthrough tests need.
+CONFORMANCE_DATAPLANE_VERSION ?= 3.19.0-debian
 # What the run deploys and what the report declares, following the checked-out
 # state: a release tag pulls the published images for that release, anything
 # else uses the dev images. Upstream rejects a floating name as the version, so
diff --git a/api/adc/types.go b/api/adc/types.go
index 63ec3d82..667f29ec 100644
--- a/api/adc/types.go
+++ b/api/adc/types.go
@@ -180,7 +180,18 @@ type StreamRoute struct {
        RemoteAddr string  `json:"remote_addr,omitempty"`
        ServerAddr string  `json:"server_addr,omitempty"`
        ServerPort int32   `json:"server_port,omitempty"`
-       SNI        string  `json:"sni,omitempty"`
+       // SNI and SNIs are the singular and plural forms of the same match; 
APISIX
+       // rejects a stream route carrying both, so only one is ever set.
+       SNI  string   `json:"sni,omitempty"`
+       SNIs []string `json:"snis,omitempty"`
+       // TLSPassthrough forwards the TLS stream to the upstream untouched 
instead
+       // of terminating it on the gateway.
+       //
+       // A listen carrying tls_passthrough alone passes every connection 
through
+       // and never reads this; a listen carrying tls alone always terminates. 
It
+       // decides only on a listen configured with both, where the matched 
stream
+       // route picks between the terminating and the prereading server.
+       TLSPassthrough *bool `json:"tls_passthrough,omitempty"`
 }
 
 // +k8s:deepcopy-gen=true
diff --git a/api/adc/zz_generated.deepcopy.go b/api/adc/zz_generated.deepcopy.go
index b117ad34..5907d1dd 100644
--- a/api/adc/zz_generated.deepcopy.go
+++ b/api/adc/zz_generated.deepcopy.go
@@ -549,6 +549,16 @@ func (in *StreamRoute) DeepCopyInto(out *StreamRoute) {
        *out = *in
        in.Metadata.DeepCopyInto(&out.Metadata)
        out.Plugins = in.Plugins.DeepCopy()
+       if in.SNIs != nil {
+               in, out := &in.SNIs, &out.SNIs
+               *out = make([]string, len(*in))
+               copy(*out, *in)
+       }
+       if in.TLSPassthrough != nil {
+               in, out := &in.TLSPassthrough, &out.TLSPassthrough
+               *out = new(bool)
+               **out = **in
+       }
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, 
creating a new StreamRoute.
diff --git a/config/manager/kustomization.yaml 
b/config/manager/kustomization.yaml
index a55e9a6b..1b8ae9fa 100644
--- a/config/manager/kustomization.yaml
+++ b/config/manager/kustomization.yaml
@@ -17,4 +17,4 @@ images:
   newTag: dev
 - name: sidecar
   newName: ghcr.io/api7/adc
-  newTag: 0.30.5
+  newTag: 0.31.0
diff --git a/docs/en/latest/concepts/gateway-api.md 
b/docs/en/latest/concepts/gateway-api.md
index 27dbd8a2..78f69366 100644
--- a/docs/en/latest/concepts/gateway-api.md
+++ b/docs/en/latest/concepts/gateway-api.md
@@ -87,7 +87,7 @@ The fields below are specified in the Gateway API 
specification but are either p
 | `spec.listeners[].port`               | Partially supported | Controls 
`server_port` route-var injection; off by default and enabled via 
[`listener_port_match_mode`](../reference/configuration-file.md) (`auto` / 
`explicit`). The controller cannot dynamically open data plane ports, so APISIX 
must already listen on the specified port. |
 | `spec.listeners[].tls.certificateRefs[].group` | Partially supported | Only 
`""` is supported; other group values cause validation failure. |
 | `spec.listeners[].tls.certificateRefs[].kind`        | Partially supported  
| Only `Secret` is supported.                                                   
                 |
-| `spec.listeners[].tls.mode`                          | Partially supported  
| `Terminate` is implemented; `Passthrough` is effectively unsupported for 
Gateway listeners.    |
+| `spec.listeners[].tls.mode`                          | Partially supported  
| `Terminate` and `Passthrough` are both implemented. A `Passthrough` listener 
needs APISIX to be listening on that port with 
[`tls_passthrough`](https://apisix.apache.org/docs/apisix/stream-proxy/); the 
controller cannot open data plane ports itself. A single port cannot mix the 
two modes. Within one Gateway, listeners that disagree on `tls.mode` for one 
port are reported `Accepted=False` with `ProtocolConflic [...]
 | `spec.listeners[].tls.frontendValidation`            | Partially supported  
| Enables downstream (client) mTLS. `caCertificateRefs` may reference a 
`ConfigMap` (Gateway API Core support) or a `Secret` (implementation-specific) 
holding the CA certificate under the `ca.crt` key; clients are then required to 
present a certificate signed by one of the referenced CAs. |
 | `spec.addresses`                                     | Not supported        
| Controller does not read or act on `spec.addresses`.                          
                 |
 
diff --git a/internal/adc/translator/l4route_test.go 
b/internal/adc/translator/l4route_test.go
index 5ac129da..dac18a91 100644
--- a/internal/adc/translator/l4route_test.go
+++ b/internal/adc/translator/l4route_test.go
@@ -291,13 +291,20 @@ func TestTranslateTLSRouteWithL4RoutePolicy(t *testing.T) 
{
                        require.NoError(t, err)
                        require.Len(t, result.Services, 1)
 
-                       // Verify stream routes are created per SNI hostname
-                       if len(tt.hostnames) > 0 {
-                               assert.Len(t, result.Services[0].StreamRoutes, 
len(tt.hostnames))
+                       // One stream route carries every hostname: the 
singular sni for a
+                       // single one, the plural snis beyond that. APISIX 
rejects both at once.
+                       require.Len(t, result.Services[0].StreamRoutes, 1)
+                       switch len(tt.hostnames) {
+                       case 1:
+                               assert.Equal(t, tt.hostnames[0], 
result.Services[0].StreamRoutes[0].SNI)
+                               assert.Empty(t, 
result.Services[0].StreamRoutes[0].SNIs)
+                       default:
+                               assert.Equal(t, tt.hostnames, 
result.Services[0].StreamRoutes[0].SNIs)
+                               assert.Empty(t, 
result.Services[0].StreamRoutes[0].SNI)
                        }
 
                        // Plugins are attached at the stream_route level so 
the APISIX stream proxy
-                       // applies them; with multiple SNIs each stream_route 
carries its own copy.
+                       // applies them.
                        require.NotEmpty(t, result.Services[0].StreamRoutes)
                        plugins := result.Services[0].StreamRoutes[0].Plugins
                        if tt.wantNoPlugins {
diff --git a/internal/adc/translator/tlsroute.go 
b/internal/adc/translator/tlsroute.go
index 2faa16b6..7d584f51 100644
--- a/internal/adc/translator/tlsroute.go
+++ b/internal/adc/translator/tlsroute.go
@@ -20,11 +20,13 @@ package translator
 import (
        "fmt"
 
+       "k8s.io/utils/ptr"
        gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
 
        adctypes "github.com/apache/apisix-ingress-controller/api/adc"
        apiv2 "github.com/apache/apisix-ingress-controller/api/v2"
        "github.com/apache/apisix-ingress-controller/internal/controller/label"
+       cutils 
"github.com/apache/apisix-ingress-controller/internal/controller/utils"
        "github.com/apache/apisix-ingress-controller/internal/id"
        "github.com/apache/apisix-ingress-controller/internal/provider"
        "github.com/apache/apisix-ingress-controller/internal/types"
@@ -34,10 +36,6 @@ func (t *Translator) TranslateTLSRoute(tctx 
*provider.TranslateContext, tlsRoute
        result := &TranslateResult{}
        rules := tlsRoute.Spec.Rules
        labels := label.GenLabel(tlsRoute)
-       hosts := make([]string, 0, len(tlsRoute.Spec.Hostnames))
-       for _, hostname := range tlsRoute.Spec.Hostnames {
-               hosts = append(hosts, string(hostname))
-       }
        for ruleIndex, rule := range rules {
                service := adctypes.NewDefaultService()
                service.Labels = labels
@@ -143,18 +141,125 @@ func (t *Translator) TranslateTLSRoute(tctx 
*provider.TranslateContext, tlsRoute
                        }
                }
 
-               for _, host := range hosts {
-                       streamRoutes, err := t.buildL4StreamRoutes(tctx, 
tlsRoute.Namespace, tlsRoute.Name, ruleIndex, "TLS", "TLSRoute", labels)
-                       if err != nil {
-                               return nil, err
+               // One set of stream routes per rule, each carrying the SNIs of 
its own
+               // port, rather than one set per hostname: buildL4StreamRoutes 
names them by
+               // rule and port only, so a per hostname loop produced N sets 
sharing one ID
+               // and only the last survived.
+               streamRoutes, err := t.buildL4StreamRoutes(tctx, 
tlsRoute.Namespace, tlsRoute.Name, ruleIndex, "TLS", "TLSRoute", labels)
+               if err != nil {
+                       return nil, err
+               }
+               for _, streamRoute := range streamRoutes {
+                       snis := tlsRouteSNIsForPort(tctx.Listeners, 
tlsRoute.Spec.Hostnames, streamRoute.ServerPort)
+                       if len(snis) == 0 {
+                               // Nothing for the stream route to match on. 
The listeners a route
+                               // attaches to already intersect its hostnames, 
so this is a guard
+                               // against emitting a route that would answer 
every SNI instead.
+                               continue
                        }
-                       for _, streamRoute := range streamRoutes {
-                               streamRoute.SNI = host
+                       // A single SNI keeps using the singular form: it is 
what every
+                       // APISIX version understands, and snis only earns its 
place once
+                       // there is more than one to match.
+                       if len(snis) == 1 {
+                               streamRoute.SNI = snis[0]
+                       } else {
+                               streamRoute.SNIs = snis
+                       }
+                       if tlsPassthroughOnPort(tctx.Listeners, 
streamRoute.ServerPort) {
+                               streamRoute.TLSPassthrough = ptr.To(true)
                        }
-                       service.StreamRoutes = append(service.StreamRoutes, 
streamRoutes...)
+                       service.StreamRoutes = append(service.StreamRoutes, 
streamRoute)
                }
 
                result.Services = append(result.Services, service)
        }
        return result, nil
 }
+
+// tlsRouteSNIsForPort returns the SNIs the stream route bound to port matches 
on.
+//
+// The set is derived from the listeners on that port alone. Handing every port
+// the route-wide union instead would let a port answer a hostname its own
+// listener never accepted, and would cost a port its catch-all as soon as a
+// listener on another port named a hostname. port 0 means the stream route
+// carries no server_port match, so every matched listener applies.
+//
+// A TLSRoute without hostnames matches everything its listeners accept, so it
+// falls back to the hostnames of the listeners on the port and, when one of 
them
+// carries no hostname either, to the catch-all "*". Emitting nothing - which 
is
+// what the per hostname loop used to do - left such a route attached but
+// unserved.
+func tlsRouteSNIsForPort(listeners []gatewayv1.Listener, routeHostnames 
[]gatewayv1.Hostname, port int32) []string {
+       matched := make([]gatewayv1.Listener, 0, len(listeners))
+       for _, listener := range listeners {
+               if listener.Protocol != gatewayv1.TLSProtocolType {
+                       continue
+               }
+               if port != 0 && listener.Port != port {
+                       continue
+               }
+               matched = append(matched, listener)
+       }
+
+       // No listener to narrow against: keep what the route itself asks for.
+       if len(matched) == 0 {
+               if len(routeHostnames) == 0 {
+                       return []string{"*"}
+               }
+               return hostnamesToSNIs(routeHostnames)
+       }
+
+       if len(routeHostnames) == 0 {
+               hostnames, matchAnyHost := cutils.UnionHostnames(matched)
+               if matchAnyHost {
+                       return []string{"*"}
+               }
+               return hostnamesToSNIs(cutils.DedupHostnames(hostnames))
+       }
+
+       snis := make([]gatewayv1.Hostname, 0, len(routeHostnames))
+       for _, hostname := range routeHostnames {
+               snis = append(snis, cutils.NarrowHostname(matched, hostname)...)
+       }
+       return hostnamesToSNIs(cutils.DedupHostnames(snis))
+}
+
+func hostnamesToSNIs(hostnames []gatewayv1.Hostname) []string {
+       snis := make([]string, 0, len(hostnames))
+       for _, hostname := range hostnames {
+               snis = append(snis, string(hostname))
+       }
+       return snis
+}
+
+// tlsPassthroughOnPort reports whether the stream routes bound to port must
+// forward the connection untouched instead of having the gateway terminate it.
+// port 0 means the StreamRoute carries no server_port match, so every matched
+// listener applies.
+//
+// Every TLS listener this route matched on the port has to agree. Within one
+// Gateway a port carrying both modes is already reported ProtocolConflict and
+// attaches no routes; a route attached to two Gateways that disagree falls 
back
+// to terminating rather than to a guess, since the physical stream listen has 
a
+// single mode.
+//
+// Two *different* routes attached to two disagreeing Gateways are each
+// translated for the mode their own listener declares, and only one of those 
is
+// the mode the listen was configured with. The controller does not configure 
the
+// stream listen, so it can neither detect that nor resolve it.
+func tlsPassthroughOnPort(listeners []gatewayv1.Listener, port int32) bool {
+       matched := false
+       for _, listener := range listeners {
+               if listener.Protocol != gatewayv1.TLSProtocolType {
+                       continue
+               }
+               if port != 0 && listener.Port != port {
+                       continue
+               }
+               if listener.TLS == nil || listener.TLS.Mode == nil || 
*listener.TLS.Mode != gatewayv1.TLSModePassthrough {
+                       return false
+               }
+               matched = true
+       }
+       return matched
+}
diff --git a/internal/adc/translator/tlsroute_test.go 
b/internal/adc/translator/tlsroute_test.go
new file mode 100644
index 00000000..b207da8b
--- /dev/null
+++ b/internal/adc/translator/tlsroute_test.go
@@ -0,0 +1,275 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package translator
+
+import (
+       "context"
+       "testing"
+
+       "github.com/go-logr/logr"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+       "k8s.io/utils/ptr"
+       gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
+
+       "github.com/apache/apisix-ingress-controller/internal/controller/config"
+       "github.com/apache/apisix-ingress-controller/internal/provider"
+)
+
+func tlsModeListener(name string, port int32, mode *gatewayv1.TLSModeType, 
hostname string) gatewayv1.Listener {
+       listener := gatewayv1.Listener{
+               Name:     gatewayv1.SectionName(name),
+               Protocol: gatewayv1.TLSProtocolType,
+               Port:     port,
+               TLS:      &gatewayv1.ListenerTLSConfig{Mode: mode},
+       }
+       if hostname != "" {
+               listener.Hostname = ptr.To(gatewayv1.Hostname(hostname))
+       }
+       return listener
+}
+
+func translateTLSRoute(t *testing.T, listeners []gatewayv1.Listener, explicit 
bool, hostnames ...string) *TranslateResult {
+       t.Helper()
+
+       // listener_port_match_mode defaults to off; auto is what the e2e and
+       // conformance runs use, and what makes the per-port fan-out observable.
+       translator := NewTranslator(logr.Discard(), 
config.ListenerPortMatchModeAuto)
+       tctx := provider.NewDefaultTranslateContext(context.Background())
+       tctx.Listeners = listeners
+       tctx.HasExplicitListenerMatch = explicit
+
+       specHostnames := make([]gatewayv1.Hostname, 0, len(hostnames))
+       for _, hostname := range hostnames {
+               specHostnames = append(specHostnames, 
gatewayv1.Hostname(hostname))
+       }
+
+       result, err := translator.TranslateTLSRoute(tctx, &gatewayv1.TLSRoute{
+               ObjectMeta: metav1.ObjectMeta{Name: "my-tls", Namespace: 
"default"},
+               Spec: gatewayv1.TLSRouteSpec{
+                       Hostnames: specHostnames,
+                       Rules: []gatewayv1.TLSRouteRule{
+                               {BackendRefs: []gatewayv1.BackendRef{}},
+                       },
+               },
+       })
+       require.NoError(t, err)
+       require.Len(t, result.Services, 1)
+       return result
+}
+
+func TestTranslateTLSRouteTLSPassthrough(t *testing.T) {
+       terminate := gatewayv1.TLSModeTerminate
+       passthrough := gatewayv1.TLSModePassthrough
+
+       for _, tc := range []struct {
+               name      string
+               listeners []gatewayv1.Listener
+               explicit  bool
+               want      []*bool
+       }{
+               {
+                       name:      "passthrough listener asks the data plane to 
pass the stream through",
+                       listeners: []gatewayv1.Listener{tlsModeListener("tls", 
9110, &passthrough, "")},
+                       explicit:  true,
+                       want:      []*bool{ptr.To(true)},
+               },
+               {
+                       name:      "terminate listener leaves the flag off",
+                       listeners: []gatewayv1.Listener{tlsModeListener("tls", 
9110, &terminate, "")},
+                       explicit:  true,
+                       want:      []*bool{nil},
+               },
+               {
+                       // tls.mode is optional and defaults to Terminate.
+                       name:      "omitted mode is terminate",
+                       listeners: []gatewayv1.Listener{tlsModeListener("tls", 
9110, nil, "")},
+                       explicit:  true,
+                       want:      []*bool{nil},
+               },
+               {
+                       name: "each port carries the mode of its own listener",
+                       listeners: []gatewayv1.Listener{
+                               tlsModeListener("terminate", 9110, &terminate, 
""),
+                               tlsModeListener("passthrough", 9120, 
&passthrough, ""),
+                       },
+                       want: []*bool{nil, ptr.To(true)},
+               },
+               {
+                       // A physical stream listen has one mode, so a port 
whose matched
+                       // listeners disagree cannot be served both ways; 
terminating is
+                       // what the gateway did before passthrough existed.
+                       name: "listeners disagreeing on one port fall back to 
terminate",
+                       listeners: []gatewayv1.Listener{
+                               tlsModeListener("a", 9110, &passthrough, ""),
+                               tlsModeListener("b", 9110, &terminate, ""),
+                       },
+                       explicit: true,
+                       want:     []*bool{nil},
+               },
+               {
+                       // No listener port to pin to: the single portless 
StreamRoute takes
+                       // the mode every matched listener agrees on.
+                       name:      "portless stream route still follows the 
listener mode",
+                       listeners: []gatewayv1.Listener{tlsModeListener("tls", 
9110, &passthrough, "")},
+                       want:      []*bool{ptr.To(true)},
+               },
+               {
+                       name:      "no matched listener leaves the flag off",
+                       listeners: nil,
+                       want:      []*bool{nil},
+               },
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       result := translateTLSRoute(t, tc.listeners, 
tc.explicit, "example.com")
+
+                       streamRoutes := result.Services[0].StreamRoutes
+                       require.Len(t, streamRoutes, len(tc.want))
+                       for i, want := range tc.want {
+                               assert.Equal(t, want, 
streamRoutes[i].TLSPassthrough, "stream route %d", i)
+                       }
+               })
+       }
+}
+
+func TestTranslateTLSRouteSNIs(t *testing.T) {
+       passthrough := gatewayv1.TLSModePassthrough
+
+       t.Run("a single hostname uses the singular sni", func(t *testing.T) {
+               result := translateTLSRoute(t, nil, false, "example.com")
+
+               streamRoutes := result.Services[0].StreamRoutes
+               require.Len(t, streamRoutes, 1)
+               assert.Equal(t, "example.com", streamRoutes[0].SNI)
+               assert.Empty(t, streamRoutes[0].SNIs)
+       })
+
+       t.Run("several hostnames share one stream route", func(t *testing.T) {
+               result := translateTLSRoute(t, nil, false, "a.example.com", 
"b.example.com")
+
+               // Regression: every hostname used to produce its own 
StreamRoute under
+               // one name, so they collided on a single id and only the last 
survived.
+               streamRoutes := result.Services[0].StreamRoutes
+               require.Len(t, streamRoutes, 1)
+               assert.Equal(t, []string{"a.example.com", "b.example.com"}, 
streamRoutes[0].SNIs)
+               assert.Empty(t, streamRoutes[0].SNI)
+       })
+
+       t.Run("no hostname falls back to the listener hostnames", func(t 
*testing.T) {
+               result := translateTLSRoute(t, []gatewayv1.Listener{
+                       tlsModeListener("tls", 9110, &passthrough, 
"*.example.com"),
+               }, true)
+
+               streamRoutes := result.Services[0].StreamRoutes
+               require.Len(t, streamRoutes, 1)
+               assert.Equal(t, "*.example.com", streamRoutes[0].SNI)
+       })
+
+       t.Run("no hostname anywhere falls back to the catch-all", func(t 
*testing.T) {
+               result := translateTLSRoute(t, []gatewayv1.Listener{
+                       tlsModeListener("tls", 9110, &passthrough, ""),
+               }, true)
+
+               streamRoutes := result.Services[0].StreamRoutes
+               require.Len(t, streamRoutes, 1)
+               assert.Equal(t, "*", streamRoutes[0].SNI)
+       })
+}
+
+func TestTranslateTLSRouteSNIsPerPort(t *testing.T) {
+       passthrough := gatewayv1.TLSModePassthrough
+
+       snisByPort := func(result *TranslateResult) map[int32][]string {
+               byPort := make(map[int32][]string)
+               for _, streamRoute := range result.Services[0].StreamRoutes {
+                       snis := streamRoute.SNIs
+                       if streamRoute.SNI != "" {
+                               snis = []string{streamRoute.SNI}
+                       }
+                       byPort[streamRoute.ServerPort] = snis
+               }
+               return byPort
+       }
+
+       // The route-wide union went to every port, so each port also answered 
the
+       // hostname only the other port's listener accepted.
+       t.Run("a hostname-less route takes the hostnames of its own port", 
func(t *testing.T) {
+               result := translateTLSRoute(t, []gatewayv1.Listener{
+                       tlsModeListener("a", 9110, &passthrough, 
"a.example.com"),
+                       tlsModeListener("b", 9120, &passthrough, 
"b.example.com"),
+               }, true)
+
+               assert.Equal(t, map[int32][]string{
+                       9110: {"a.example.com"},
+                       9120: {"b.example.com"},
+               }, snisByPort(result))
+       })
+
+       // A listener carrying no hostname accepts every name. Collecting 
hostnames
+       // route-wide cost that port its catch-all as soon as a listener on 
another
+       // port named a hostname.
+       t.Run("a port whose listener names no hostname keeps the catch-all", 
func(t *testing.T) {
+               result := translateTLSRoute(t, []gatewayv1.Listener{
+                       tlsModeListener("named", 9110, &passthrough, 
"a.example.com"),
+                       tlsModeListener("any", 9120, &passthrough, ""),
+               }, true)
+
+               assert.Equal(t, map[int32][]string{
+                       9110: {"a.example.com"},
+                       9120: {"*"},
+               }, snisByPort(result))
+       })
+
+       t.Run("route hostnames are narrowed to the listeners of each port", 
func(t *testing.T) {
+               result := translateTLSRoute(t, []gatewayv1.Listener{
+                       tlsModeListener("a", 9110, &passthrough, 
"a.example.com"),
+                       tlsModeListener("b", 9120, &passthrough, 
"b.example.com"),
+               }, true, "*.example.com")
+
+               assert.Equal(t, map[int32][]string{
+                       9110: {"a.example.com"},
+                       9120: {"b.example.com"},
+               }, snisByPort(result))
+       })
+
+       // Both listeners on the port are covered by the route's wildcard, so 
the
+       // route answers both names rather than only the first intersection.
+       t.Run("one port with several listeners keeps every intersection", 
func(t *testing.T) {
+               result := translateTLSRoute(t, []gatewayv1.Listener{
+                       tlsModeListener("a", 9110, &passthrough, 
"a.example.com"),
+                       tlsModeListener("b", 9110, &passthrough, 
"b.example.com"),
+               }, true, "*.example.com")
+
+               assert.Equal(t, map[int32][]string{
+                       9110: {"a.example.com", "b.example.com"},
+               }, snisByPort(result))
+       })
+
+       // A listener hostname that covers the route hostname accepts it whole, 
so
+       // the route keeps its own name instead of the listener's.
+       t.Run("a wildcard listener keeps the route hostname", func(t 
*testing.T) {
+               result := translateTLSRoute(t, []gatewayv1.Listener{
+                       tlsModeListener("wildcard", 9110, &passthrough, 
"*.example.com"),
+               }, true, "a.example.com")
+
+               assert.Equal(t, map[int32][]string{
+                       9110: {"a.example.com"},
+               }, snisByPort(result))
+       })
+}
diff --git a/internal/controller/tlsroute_controller.go 
b/internal/controller/tlsroute_controller.go
index f1b1792f..1346c3ef 100644
--- a/internal/controller/tlsroute_controller.go
+++ b/internal/controller/tlsroute_controller.go
@@ -318,7 +318,8 @@ func (r *TLSRouteReconciler) Reconcile(ctx context.Context, 
req ctrl.Request) (c
                        acceptStatus.msg = err.Error()
                }
                // Populate the matched listeners so the translator can derive 
the
-               // StreamRoute server_port from the listener the route attaches 
to.
+               // StreamRoute server_port and its tls.mode from the listener 
the route
+               // attaches to.
                if len(gateway.Listeners) > 0 {
                        tctx.Listeners = appendListeners(tctx.Listeners, 
gateway.Listeners...)
                } else if gateway.Listener != nil {
@@ -348,12 +349,26 @@ func (r *TLSRouteReconciler) Reconcile(ctx 
context.Context, req ctrl.Request) (c
        if r.supportsL4RoutePolicy {
                ProcessL4RoutePolicy(r.Client, r.Log, tctx, tr.Namespace, 
tr.Name, types.KindTLSRoute)
        }
+
+       // The hostnames become the SNIs the stream routes match on, so they 
have to be
+       // narrowed to what the listeners accept first. Every Gateway shares 
one physical
+       // stream listen, so a route keeping its own broader hostname does not 
merely
+       // over-serve: it takes that name from the route whose listener does 
accept it.
+       filteredTLSRoute, hostnameErr := filterTLSRouteHostnames(gateways, 
tr.DeepCopy())
+       if hostnameErr != nil {
+               acceptStatus.status = false
+               acceptStatus.msg = hostnameErr.Error()
+       }
        // Translate and sync before writing Accepted=True. A translation or
        // buildConfig failure from Provider.Update used to leave a green status
        // and only requeue, matching neither Ingress nor ApisixGlobalRule.
        var syncErr error
-       if isRouteAccepted(gateways) {
-               if syncErr = r.Provider.Update(ctx, tctx, tr); syncErr != nil {
+       if isRouteAccepted(gateways) && hostnameErr == nil {
+               routeToUpdate := tr
+               if filteredTLSRoute != nil {
+                       routeToUpdate = filteredTLSRoute
+               }
+               if syncErr = r.Provider.Update(ctx, tctx, routeToUpdate); 
syncErr != nil {
                        acceptStatus.status = false
                        acceptStatus.msg = syncErr.Error()
                }
@@ -386,7 +401,7 @@ func (r *TLSRouteReconciler) Reconcile(ctx context.Context, 
req ctrl.Request) (c
                }),
        })
        UpdateStatus(r.Updater, r.Log, tctx)
-       if isRouteAccepted(gateways) {
+       if isRouteAccepted(gateways) && hostnameErr == nil {
                return ctrl.Result{}, syncErr
        }
 
diff --git a/internal/controller/utils.go b/internal/controller/utils.go
index bb473b58..bc328f4c 100644
--- a/internal/controller/utils.go
+++ b/internal/controller/utils.go
@@ -48,6 +48,7 @@ import (
        apiv2 "github.com/apache/apisix-ingress-controller/api/v2"
        "github.com/apache/apisix-ingress-controller/internal/controller/config"
        
"github.com/apache/apisix-ingress-controller/internal/controller/indexer"
+       cutils 
"github.com/apache/apisix-ingress-controller/internal/controller/utils"
        "github.com/apache/apisix-ingress-controller/internal/provider"
        sslutils "github.com/apache/apisix-ingress-controller/internal/ssl"
        "github.com/apache/apisix-ingress-controller/internal/types"
@@ -726,7 +727,7 @@ func listenerHostnameIntersectWithRouteHostnames(listener 
gatewayv1.Listener, ho
 
        // iterate over all the hostnames and check that at least one intersect 
with the listener hostname
        for _, hostname := range hostnames {
-               if HostnamesIntersect(string(*listener.Hostname), 
string(hostname)) {
+               if cutils.HostnamesIntersect(string(*listener.Hostname), 
string(hostname)) {
                        return true
                }
        }
@@ -734,57 +735,6 @@ func listenerHostnameIntersectWithRouteHostnames(listener 
gatewayv1.Listener, ho
        return false
 }
 
-func HostnamesIntersect(a, b string) bool {
-       return HostnamesMatch(a, b) || HostnamesMatch(b, a)
-}
-
-// HostnamesMatch checks that the hostnameB matches the hostnameA. HostnameA 
is treated as mask
-// to be checked against the hostnameB.
-func HostnamesMatch(hostnameA, hostnameB string) bool {
-       // the hostnames are in the form of "foo.bar.com"; split them
-       // in a slice of substrings
-       hostnameALabels := strings.Split(hostnameA, ".")
-       hostnameBLabels := strings.Split(hostnameB, ".")
-
-       var a, b int
-       var wildcard bool
-
-       // iterate over the parts of both the hostnames
-       for a, b = 0, 0; a < len(hostnameALabels) && b < len(hostnameBLabels); 
a, b = a+1, b+1 {
-               var matchFound bool
-
-               // if the current part of B is a wildcard, we need to find the 
first
-               // A part that matches with the following B part
-               if wildcard {
-                       for ; b < len(hostnameBLabels); b++ {
-                               if hostnameALabels[a] == hostnameBLabels[b] {
-                                       matchFound = true
-                                       break
-                               }
-                       }
-               }
-
-               // if no match was found, the hostnames don't match
-               if wildcard && !matchFound {
-                       return false
-               }
-
-               // check if at least on of the current parts are a wildcard; if 
so, continue
-               if hostnameALabels[a] == "*" {
-                       wildcard = true
-                       continue
-               }
-               // reset the wildcard  variables
-               wildcard = false
-
-               // if the current a part is different from the b part, the 
hostnames are incompatible
-               if hostnameALabels[a] != hostnameBLabels[b] {
-                       return false
-               }
-       }
-       return len(hostnameBLabels)-b == len(hostnameALabels)-a
-}
-
 func routeMatchesListenerAllowedRoutes(
        ctx context.Context,
        mgrc client.Client,
@@ -1392,75 +1342,71 @@ func FullTypeName(a any) string {
 // filterHostnames accepts a list of gateways and an HTTPRoute, and returns a 
copy of the HTTPRoute with only the hostnames that match the listener hostnames 
of the gateways.
 // If the HTTPRoute hostnames do not intersect with the listener hostnames of 
the gateways, it returns an ErrNoMatchingListenerHostname error.
 func filterHostnames(gateways []RouteParentRefContext, httpRoute 
*gatewayv1.HTTPRoute) (*gatewayv1.HTTPRoute, error) {
-       filteredHostnames := make([]gatewayv1.Hostname, 0)
-
-       // If the HTTPRoute does not specify hostnames, we use the union of the 
listener hostnames of all supported gateways
-       // If any supported listener does not specify a hostname, the HTTPRoute 
hostnames remain empty to match any hostname
-       if len(httpRoute.Spec.Hostnames) == 0 {
-               hostnames, matchAnyHost := getUnionOfGatewayHostnames(gateways)
-               if matchAnyHost {
-                       return httpRoute, nil
-               }
-               filteredHostnames = hostnames
-       } else {
-               // If the HTTPRoute specifies hostnames, we need to find the 
intersection with the gateway listener hostnames
-               for _, hostname := range httpRoute.Spec.Hostnames {
-                       if hostnameMatching := 
getMinimumHostnameIntersection(gateways, hostname); hostnameMatching != "" {
-                               filteredHostnames = append(filteredHostnames, 
hostnameMatching)
-                       }
-               }
-               if len(filteredHostnames) == 0 {
-                       return httpRoute, ErrNoMatchingListenerHostname
-               }
+       hostnames, err := intersectRouteHostnames(gateways, 
httpRoute.Spec.Hostnames)
+       if err != nil {
+               return httpRoute, err
        }
-
-       httpRoute.Spec.Hostnames = filteredHostnames
+       httpRoute.Spec.Hostnames = hostnames
        return httpRoute, nil
 }
 
-// getUnionOfGatewayHostnames returns the union of the hostnames specified in 
all supported gateways
-// The second return value indicates whether any listener can match any 
hostname
-func getUnionOfGatewayHostnames(gateways []RouteParentRefContext) 
([]gatewayv1.Hostname, bool) {
-       hostnames := make([]gatewayv1.Hostname, 0)
+// filterTLSRouteHostnames is filterHostnames for a TLSRoute. Its hostnames 
become
+// the SNIs the stream routes match on, so a route left carrying its own 
broader
+// hostname would serve names the listener it attached to never accepted - 
and, on
+// a single shared stream listen, would take them from the route that should 
have.
+func filterTLSRouteHostnames(gateways []RouteParentRefContext, tlsRoute 
*gatewayv1.TLSRoute) (*gatewayv1.TLSRoute, error) {
+       hostnames, err := intersectRouteHostnames(gateways, 
tlsRoute.Spec.Hostnames)
+       if err != nil {
+               return tlsRoute, err
+       }
+       tlsRoute.Spec.Hostnames = hostnames
+       return tlsRoute, nil
+}
 
-       for _, gateway := range gateways {
-               for _, listener := range listenersForGatewayContext(gateway) {
-                       // Only consider listeners that can effectively 
configure hostnames (HTTP, HTTPS, or TLS).
-                       if !isListenerHostnameEffective(listener) {
-                               continue
-                       }
-                       if listener.Hostname == nil {
-                               return nil, true
-                       }
-                       hostnames = append(hostnames, *listener.Hostname)
+// intersectRouteHostnames narrows a route's hostnames to what the listeners it
+// attached to actually accept.
+//
+// A route without hostnames takes the union of the listener hostnames, and 
stays
+// empty - matching any hostname - as soon as one supported listener carries no
+// hostname of its own. Otherwise every hostname is replaced by its 
intersections
+// with the listener hostnames, and a route that intersects with none is
+// ErrNoMatchingListenerHostname.
+func intersectRouteHostnames(gateways []RouteParentRefContext, routeHostnames 
[]gatewayv1.Hostname) ([]gatewayv1.Hostname, error) {
+       listeners := hostnameEffectiveListeners(acceptedParentRefs(gateways))
+
+       if len(routeHostnames) == 0 {
+               hostnames, matchAnyHost := cutils.UnionHostnames(listeners)
+               if matchAnyHost {
+                       return routeHostnames, nil
                }
+               return cutils.DedupHostnames(hostnames), nil
        }
 
-       return hostnames, false
+       filteredHostnames := make([]gatewayv1.Hostname, 0, len(routeHostnames))
+       for _, hostname := range routeHostnames {
+               filteredHostnames = append(filteredHostnames, 
cutils.NarrowHostname(listeners, hostname)...)
+       }
+       filteredHostnames = cutils.DedupHostnames(filteredHostnames)
+       if len(filteredHostnames) == 0 {
+               return nil, ErrNoMatchingListenerHostname
+       }
+       return filteredHostnames, nil
 }
 
-// getMinimumHostnameIntersection returns the smallest intersection hostname
-// - If the listener hostname is empty, return the HTTPRoute hostname
-// - If the listener hostname is a wildcard of the HTTPRoute hostname, return 
the HTTPRoute hostname
-// - If the HTTPRoute hostname is a wildcard of the listener hostname, return 
the listener hostname
-// - If the HTTPRoute hostname and listener hostname are the same, return it
-// - If none of the above, return an empty string
-func getMinimumHostnameIntersection(gateways []RouteParentRefContext, hostname 
gatewayv1.Hostname) gatewayv1.Hostname {
+// hostnameEffectiveListeners flattens the listeners the route attached to,
+// keeping only those a hostname can be matched against.
+func hostnameEffectiveListeners(gateways []RouteParentRefContext) 
[]gatewayv1.Listener {
+       listeners := make([]gatewayv1.Listener, 0, len(gateways))
        for _, gateway := range gateways {
                for _, listener := range listenersForGatewayContext(gateway) {
-                       if listener.Hostname == nil || *listener.Hostname == "" 
{
-                               return hostname
-                       }
-                       if HostnamesMatch(string(*listener.Hostname), 
string(hostname)) {
-                               return hostname
-                       }
-                       if HostnamesMatch(string(hostname), 
string(*listener.Hostname)) {
-                               return *listener.Hostname
+                       // Only consider listeners that can effectively 
configure hostnames (HTTP, HTTPS, or TLS).
+                       if !isListenerHostnameEffective(listener) {
+                               continue
                        }
+                       listeners = append(listeners, listener)
                }
        }
-
-       return ""
+       return listeners
 }
 
 func listenersForGatewayContext(gw RouteParentRefContext) []gatewayv1.Listener 
{
@@ -1488,6 +1434,33 @@ func appendListeners(target []gatewayv1.Listener, source 
...gatewayv1.Listener)
        return append(target, source...)
 }
 
+// acceptedParentRefs keeps only the contexts whose parentRef was accepted.
+//
+// ParseRouteParentRefs returns a context per parentRef, accepted or not, and a
+// rejected one carries no matched listener at all - so 
listenersForGatewayContext
+// falls back to every listener on its Gateway. A hostname that only such a
+// Gateway accepts would then survive the intersection and be served through 
the
+// parentRef that was accepted. Only an accepted parent may widen the set.
+//
+// When nothing was accepted the contexts are returned unchanged: the route is
+// not translated in that case, and narrowing here would only change which
+// reason its status reports.
+func acceptedParentRefs(gateways []RouteParentRefContext) 
[]RouteParentRefContext {
+       accepted := make([]RouteParentRefContext, 0, len(gateways))
+       for _, gateway := range gateways {
+               for _, condition := range gateway.Conditions {
+                       if condition.Type == 
string(gatewayv1.RouteConditionAccepted) && condition.Status == 
metav1.ConditionTrue {
+                               accepted = append(accepted, gateway)
+                               break
+                       }
+               }
+       }
+       if len(accepted) == 0 {
+               return gateways
+       }
+       return accepted
+}
+
 func isRouteAccepted(gateways []RouteParentRefContext) bool {
        for _, gateway := range gateways {
                for _, condition := range gateway.Conditions {
diff --git a/internal/controller/utils/hostname.go 
b/internal/controller/utils/hostname.go
new file mode 100644
index 00000000..18b7bd4f
--- /dev/null
+++ b/internal/controller/utils/hostname.go
@@ -0,0 +1,130 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package utils
+
+import (
+       "strings"
+
+       gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
+)
+
+func HostnamesIntersect(a, b string) bool {
+       return HostnamesMatch(a, b) || HostnamesMatch(b, a)
+}
+
+// HostnamesMatch checks that the hostnameB matches the hostnameA. HostnameA 
is treated as mask
+// to be checked against the hostnameB.
+func HostnamesMatch(hostnameA, hostnameB string) bool {
+       // the hostnames are in the form of "foo.bar.com"; split them
+       // in a slice of substrings
+       hostnameALabels := strings.Split(hostnameA, ".")
+       hostnameBLabels := strings.Split(hostnameB, ".")
+
+       var a, b int
+       var wildcard bool
+
+       // iterate over the parts of both the hostnames
+       for a, b = 0, 0; a < len(hostnameALabels) && b < len(hostnameBLabels); 
a, b = a+1, b+1 {
+               var matchFound bool
+
+               // if the current part of B is a wildcard, we need to find the 
first
+               // A part that matches with the following B part
+               if wildcard {
+                       for ; b < len(hostnameBLabels); b++ {
+                               if hostnameALabels[a] == hostnameBLabels[b] {
+                                       matchFound = true
+                                       break
+                               }
+                       }
+               }
+
+               // if no match was found, the hostnames don't match
+               if wildcard && !matchFound {
+                       return false
+               }
+
+               // check if at least on of the current parts are a wildcard; if 
so, continue
+               if hostnameALabels[a] == "*" {
+                       wildcard = true
+                       continue
+               }
+               // reset the wildcard  variables
+               wildcard = false
+
+               // if the current a part is different from the b part, the 
hostnames are incompatible
+               if hostnameALabels[a] != hostnameBLabels[b] {
+                       return false
+               }
+       }
+       return len(hostnameBLabels)-b == len(hostnameALabels)-a
+}
+
+// NarrowHostname returns every intersection of routeHostname with the 
hostnames
+// of the given listeners.
+//
+// A listener carrying no hostname, or one whose hostname is a mask covering
+// routeHostname, accepts routeHostname whole and is then the only 
intersection.
+// Otherwise each listener hostname that routeHostname covers is an 
intersection
+// of its own: keeping only the first - which is what looking up a single
+// "smallest intersection" does - silently drops the traffic the remaining
+// listeners accept.
+//
+// An empty result means the listeners accept none of routeHostname.
+func NarrowHostname(listeners []gatewayv1.Listener, routeHostname 
gatewayv1.Hostname) []gatewayv1.Hostname {
+       narrowed := make([]gatewayv1.Hostname, 0, len(listeners))
+       for _, listener := range listeners {
+               if listener.Hostname == nil || *listener.Hostname == "" ||
+                       HostnamesMatch(string(*listener.Hostname), 
string(routeHostname)) {
+                       return []gatewayv1.Hostname{routeHostname}
+               }
+               if HostnamesMatch(string(routeHostname), 
string(*listener.Hostname)) {
+                       narrowed = append(narrowed, *listener.Hostname)
+               }
+       }
+       return narrowed
+}
+
+// UnionHostnames returns the union of the listener hostnames. The second 
return
+// value reports that a listener carries no hostname of its own and therefore
+// accepts every hostname, which makes the union meaningless.
+func UnionHostnames(listeners []gatewayv1.Listener) ([]gatewayv1.Hostname, 
bool) {
+       hostnames := make([]gatewayv1.Hostname, 0, len(listeners))
+       for _, listener := range listeners {
+               if listener.Hostname == nil || *listener.Hostname == "" {
+                       return nil, true
+               }
+               hostnames = append(hostnames, *listener.Hostname)
+       }
+       return hostnames, false
+}
+
+// DedupHostnames drops repeated hostnames, keeping the first occurrence.
+// Listeners are aggregated across Gateways, so the same hostname can be
+// contributed more than once.
+func DedupHostnames(hostnames []gatewayv1.Hostname) []gatewayv1.Hostname {
+       deduped := make([]gatewayv1.Hostname, 0, len(hostnames))
+       seen := make(map[gatewayv1.Hostname]struct{}, len(hostnames))
+       for _, hostname := range hostnames {
+               if _, ok := seen[hostname]; ok {
+                       continue
+               }
+               seen[hostname] = struct{}{}
+               deduped = append(deduped, hostname)
+       }
+       return deduped
+}
diff --git a/internal/controller/utils_hostname_test.go 
b/internal/controller/utils_hostname_test.go
index 031b0fe5..5141b8bd 100644
--- a/internal/controller/utils_hostname_test.go
+++ b/internal/controller/utils_hostname_test.go
@@ -21,8 +21,11 @@ import (
        "testing"
 
        "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
        gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
+
+       cutils 
"github.com/apache/apisix-ingress-controller/internal/controller/utils"
 )
 
 func TestListenersForGatewayContext(t *testing.T) {
@@ -86,7 +89,7 @@ func TestListenersForGatewayContext(t *testing.T) {
        }
 }
 
-func TestGetUnionOfGatewayHostnames(t *testing.T) {
+func TestHostnameEffectiveListenersUnion(t *testing.T) {
        fooHostname := gatewayv1.Hostname("foo.example.com")
        barHostname := gatewayv1.Hostname("bar.example.com")
 
@@ -101,7 +104,7 @@ func TestGetUnionOfGatewayHostnames(t *testing.T) {
                        },
                }
 
-               hostnames, matchAny := getUnionOfGatewayHostnames(gateways)
+               hostnames, matchAny := 
cutils.UnionHostnames(hostnameEffectiveListeners(gateways))
                assert.False(t, matchAny)
                assert.Equal(t, []gatewayv1.Hostname{fooHostname, barHostname}, 
hostnames)
        })
@@ -115,13 +118,13 @@ func TestGetUnionOfGatewayHostnames(t *testing.T) {
                        },
                }
 
-               hostnames, matchAny := getUnionOfGatewayHostnames(gateways)
+               hostnames, matchAny := 
cutils.UnionHostnames(hostnameEffectiveListeners(gateways))
                assert.True(t, matchAny)
                assert.Nil(t, hostnames)
        })
 }
 
-func TestGetMinimumHostnameIntersection(t *testing.T) {
+func TestNarrowHostnameAcrossListeners(t *testing.T) {
        fooHostname := gatewayv1.Hostname("foo.example.com")
        routeHostname := gatewayv1.Hostname("foo.example.com")
        wildcardHostname := gatewayv1.Hostname("*.example.com")
@@ -136,7 +139,8 @@ func TestGetMinimumHostnameIntersection(t *testing.T) {
                        },
                }
 
-               assert.Equal(t, routeHostname, 
getMinimumHostnameIntersection(gateways, routeHostname))
+               assert.Equal(t, []gatewayv1.Hostname{routeHostname},
+                       
cutils.NarrowHostname(hostnameEffectiveListeners(gateways), routeHostname))
        })
 
        t.Run("returns empty when there is no listener match", func(t 
*testing.T) {
@@ -148,10 +152,63 @@ func TestGetMinimumHostnameIntersection(t *testing.T) {
                        },
                }
                unmatched := gatewayv1.Hostname("bar.example.com")
-               assert.Equal(t, gatewayv1.Hostname(""), 
getMinimumHostnameIntersection(gateways, unmatched))
+               assert.Empty(t, 
cutils.NarrowHostname(hostnameEffectiveListeners(gateways), unmatched))
        })
 }
 
+// A route hostname that covers several listener hostnames intersects with 
every
+// one of them. Keeping only the first left the names the other listeners 
accept
+// unserved.
+func TestIntersectRouteHostnamesKeepsEveryIntersection(t *testing.T) {
+       aHostname := gatewayv1.Hostname("a.example.com")
+       bHostname := gatewayv1.Hostname("b.example.com")
+       gateways := []RouteParentRefContext{
+               {
+                       Listeners: []gatewayv1.Listener{
+                               {Name: "a", Protocol: 
gatewayv1.TLSProtocolType, Port: gatewayv1.PortNumber(443), Hostname: 
&aHostname},
+                               {Name: "b", Protocol: 
gatewayv1.TLSProtocolType, Port: gatewayv1.PortNumber(443), Hostname: 
&bHostname},
+                       },
+                       Conditions: []metav1.Condition{
+                               {
+                                       Type:   
string(gatewayv1.RouteConditionAccepted),
+                                       Status: metav1.ConditionTrue,
+                               },
+                       },
+               },
+       }
+
+       got, err := intersectRouteHostnames(gateways, 
[]gatewayv1.Hostname{"*.example.com"})
+       require.NoError(t, err)
+       assert.Equal(t, []gatewayv1.Hostname{aHostname, bHostname}, got)
+}
+
+// The same hostname contributed by two listeners is one hostname: listeners 
are
+// aggregated across Gateways without de-duplication.
+func TestIntersectRouteHostnamesDedups(t *testing.T) {
+       hostname := gatewayv1.Hostname("a.example.com")
+       listener := gatewayv1.Listener{
+               Name: "a", Protocol: gatewayv1.TLSProtocolType, Port: 
gatewayv1.PortNumber(443), Hostname: &hostname,
+       }
+       accepted := []metav1.Condition{
+               {
+                       Type:   string(gatewayv1.RouteConditionAccepted),
+                       Status: metav1.ConditionTrue,
+               },
+       }
+       gateways := []RouteParentRefContext{
+               {Listeners: []gatewayv1.Listener{listener}, Conditions: 
accepted},
+               {Listeners: []gatewayv1.Listener{listener}, Conditions: 
accepted},
+       }
+
+       got, err := intersectRouteHostnames(gateways, 
[]gatewayv1.Hostname{"*.example.com"})
+       require.NoError(t, err)
+       assert.Equal(t, []gatewayv1.Hostname{hostname}, got)
+
+       got, err = intersectRouteHostnames(gateways, nil)
+       require.NoError(t, err)
+       assert.Equal(t, []gatewayv1.Hostname{hostname}, got)
+}
+
 func TestFilterHostnamesWithMatchedListeners(t *testing.T) {
        fooHostname := gatewayv1.Hostname("foo.example.com")
        barHostname := gatewayv1.Hostname("bar.example.com")
@@ -211,6 +268,97 @@ func TestFilterHostnamesNoMatchedListeners(t *testing.T) {
        assert.ErrorIs(t, err, ErrNoMatchingListenerHostname)
 }
 
+// The Gateway API conformance case TLSRouteHostnameIntersection turns on this:
+// four Gateways whose TLS listeners carry different hostnames all resolve to 
one
+// physical stream listen, so a TLSRoute keeping its own hostname verbatim 
serves
+// SNIs its listener never accepted and steals them from the route whose 
listener
+// did.
+func TestFilterTLSRouteHostnames(t *testing.T) {
+       exact := gatewayv1.Hostname("abc.example.com")
+       moreSpecificWildcard := gatewayv1.Hostname("*.example.com")
+       lessSpecificWildcard := gatewayv1.Hostname("*.com")
+
+       tlsListener := func(name string, hostname *gatewayv1.Hostname) 
gatewayv1.Listener {
+               return gatewayv1.Listener{
+                       Name:     gatewayv1.SectionName(name),
+                       Protocol: gatewayv1.TLSProtocolType,
+                       Port:     443,
+                       Hostname: hostname,
+               }
+       }
+       route := func(hostnames ...gatewayv1.Hostname) *gatewayv1.TLSRoute {
+               return &gatewayv1.TLSRoute{
+                       ObjectMeta: metav1.ObjectMeta{Name: "test", Namespace: 
"default"},
+                       Spec:       gatewayv1.TLSRouteSpec{Hostnames: 
hostnames},
+               }
+       }
+
+       for _, tc := range []struct {
+               name      string
+               listener  *gatewayv1.Hostname
+               hostnames []gatewayv1.Hostname
+               want      []gatewayv1.Hostname
+               wantErr   bool
+       }{
+               {
+                       name:      "a wildcard route narrows to the exact 
listener hostname",
+                       listener:  &exact,
+                       hostnames: []gatewayv1.Hostname{moreSpecificWildcard},
+                       want:      []gatewayv1.Hostname{exact},
+               },
+               {
+                       name:      "a broader wildcard route narrows to the 
listener wildcard",
+                       listener:  &moreSpecificWildcard,
+                       hostnames: []gatewayv1.Hostname{lessSpecificWildcard},
+                       want:      []gatewayv1.Hostname{moreSpecificWildcard},
+               },
+               {
+                       name:      "an exact route under a listener wildcard 
keeps its own hostname",
+                       listener:  &moreSpecificWildcard,
+                       hostnames: []gatewayv1.Hostname{exact},
+                       want:      []gatewayv1.Hostname{exact},
+               },
+               {
+                       name:      "a listener without a hostname leaves the 
route alone",
+                       listener:  nil,
+                       hostnames: []gatewayv1.Hostname{lessSpecificWildcard},
+                       want:      []gatewayv1.Hostname{lessSpecificWildcard},
+               },
+               {
+                       name:      "a route without hostnames takes the 
listener hostname",
+                       listener:  &moreSpecificWildcard,
+                       hostnames: nil,
+                       want:      []gatewayv1.Hostname{moreSpecificWildcard},
+               },
+               {
+                       name:      "a route without hostnames under a 
hostname-less listener matches anything",
+                       listener:  nil,
+                       hostnames: nil,
+                       want:      nil,
+               },
+               {
+                       name:      "no intersection is rejected",
+                       listener:  &exact,
+                       hostnames: 
[]gatewayv1.Hostname{gatewayv1.Hostname("other.example.net")},
+                       wantErr:   true,
+               },
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       gateways := []RouteParentRefContext{
+                               {Listeners: 
[]gatewayv1.Listener{tlsListener("tls", tc.listener)}},
+                       }
+
+                       filtered, err := filterTLSRouteHostnames(gateways, 
route(tc.hostnames...).DeepCopy())
+                       if tc.wantErr {
+                               assert.ErrorIs(t, err, 
ErrNoMatchingListenerHostname)
+                               return
+                       }
+                       assert.NoError(t, err)
+                       assert.Equal(t, tc.want, filtered.Spec.Hostnames)
+               })
+       }
+}
+
 func TestAppendListeners(t *testing.T) {
        listenerA := gatewayv1.Listener{Name: "a", Port: 80}
        listenerB := gatewayv1.Listener{Name: "b", Port: 81}
@@ -249,3 +397,59 @@ func TestAppendListeners(t *testing.T) {
                })
        }
 }
+
+// A rejected parentRef carries no matched listener, so 
listenersForGatewayContext
+// falls back to every listener on its Gateway. Without filtering, a hostname 
only
+// that Gateway accepts survives the intersection and the TLS translator 
serves it
+// as an SNI through the parent that was accepted.
+func TestIntersectRouteHostnamesIgnoresRejectedParents(t *testing.T) {
+       acceptedHost := gatewayv1.Hostname("a.example.com")
+       rejectedHost := gatewayv1.Hostname("b.example.com")
+
+       gatewayWith := func(hostname gatewayv1.Hostname) *gatewayv1.Gateway {
+               h := hostname
+               return &gatewayv1.Gateway{
+                       ObjectMeta: metav1.ObjectMeta{Name: string(hostname), 
Namespace: "default"},
+                       Spec: gatewayv1.GatewaySpec{
+                               Listeners: []gatewayv1.Listener{{
+                                       Name: "tls",
+                                       Port: 443,
+                                       // Has to be a protocol 
isListenerHostnameEffective accepts, or
+                                       // the union branch skips the listener 
and reads as "no hostnames".
+                                       Protocol: gatewayv1.TLSProtocolType,
+                                       Hostname: &h,
+                               }},
+                       },
+               }
+       }
+       condition := func(status metav1.ConditionStatus) []metav1.Condition {
+               return []metav1.Condition{{
+                       Type:   string(gatewayv1.RouteConditionAccepted),
+                       Status: status,
+               }}
+       }
+
+       accepted := gatewayWith(acceptedHost)
+       rejected := gatewayWith(rejectedHost)
+       gateways := []RouteParentRefContext{
+               {
+                       Gateway:    accepted,
+                       Listeners:  accepted.Spec.Listeners,
+                       Conditions: condition(metav1.ConditionTrue),
+               },
+               {
+                       // No matched listener, as ParseRouteParentRefs leaves 
it.
+                       Gateway:    rejected,
+                       Conditions: condition(metav1.ConditionFalse),
+               },
+       }
+
+       got, err := intersectRouteHostnames(gateways, 
[]gatewayv1.Hostname{acceptedHost, rejectedHost})
+       require.NoError(t, err)
+       require.Equal(t, []gatewayv1.Hostname{acceptedHost}, got)
+
+       // A route without hostnames takes the union, which must not be widened 
either.
+       got, err = intersectRouteHostnames(gateways, nil)
+       require.NoError(t, err)
+       require.Equal(t, []gatewayv1.Hostname{acceptedHost}, got)
+}
diff --git a/test/conformance/conformance_test.go 
b/test/conformance/conformance_test.go
index 12f483b6..c583d253 100644
--- a/test/conformance/conformance_test.go
+++ b/test/conformance/conformance_test.go
@@ -30,17 +30,6 @@ var skippedTestsForSSL = []string{
        tests.HTTPRouteRedirectPortAndScheme.ShortName,
 }
 
-// APISIX terminates TLS on its stream proxy and matches stream routes by SNI,
-// which implements TLSRoute in Terminate mode (declared via the
-// TLSRouteModeTerminate feature) but never forwards the encrypted stream
-// untouched. Every test below pins its listener to mode: Passthrough.
-var skippedTestsForTLSPassthrough = []string{
-       tests.TLSRouteSimpleSameNamespace.ShortName,
-       tests.TLSRouteHostnameIntersection.ShortName,
-       tests.TLSRouteInvalidBackendRefNonexistent.ShortName,
-       tests.TLSRouteInvalidBackendRefUnknownKind.ShortName,
-}
-
 // Known gaps tracked for follow-up. These are genuine feature gaps rather than
 // architectural limits, so they are expected to shrink over time.
 var skippedTestsForKnownGaps = []string{
@@ -62,6 +51,18 @@ var skippedTestsForKnownGaps = []string{
        // A single HTTPRoute attached to several Gateways is not served from 
each
        // parent independently.
        tests.HTTPRouteMultipleGateways.ShortName,
+
+       // The same limitation for TLSRoute, and not something the translator 
can
+       // fix. The test stands four Gateways up on port 443 with different 
listener
+       // hostnames; every Gateway resolves to the one data plane address and 
the one
+       // physical stream listen, so their SNI namespaces are shared. The 
Gateway
+       // whose listener carries no hostname keeps its route's "*.com" 
verbatim -
+       // correctly, and its own subtest depends on it - which then also 
answers
+       // "non.matching.com" on the address of the Gateway that should have 
rejected
+       // it. Which Gateway a connection was addressed to is not on the wire, 
so
+       // there is nothing left to discriminate on. Every other assertion in 
this
+       // test passes, including the hostname intersections themselves.
+       tests.TLSRouteHostnameIntersection.ShortName,
 }
 
 func TestGatewayAPIConformance(t *testing.T) {
@@ -70,7 +71,6 @@ func TestGatewayAPIConformance(t *testing.T) {
        opts.CleanupBaseResources = true
        opts.GatewayClassName = gatewayClassName
        opts.SkipTests = append(opts.SkipTests, skippedTestsForSSL...)
-       opts.SkipTests = append(opts.SkipTests, 
skippedTestsForTLSPassthrough...)
        opts.SkipTests = append(opts.SkipTests, skippedTestsForKnownGaps...)
        // Implementation is left to the flags DefaultOptions already applied.
        // Assigning it here would override them and pin the report to a stale 
version.
diff --git a/test/conformance/suite_test.go b/test/conformance/suite_test.go
index 0ae3bc70..6480fa19 100644
--- a/test/conformance/suite_test.go
+++ b/test/conformance/suite_test.go
@@ -154,6 +154,13 @@ func TestMain(m *testing.M) {
                ServiceType:       "LoadBalancer",
                ServiceHTTPPort:   80,
                ServiceHTTPSPort:  443,
+               // The TLSRoute tests pin their Gateway listener to port 443 in
+               // Passthrough mode and dial the Gateway address there, so 443 
has to
+               // reach APISIX's stream tls_passthrough listen rather than its 
HTTP ssl
+               // listen. One port cannot serve both, and the HTTPRoute tests 
that would
+               // want HTTP-over-TLS on 443 are skipped for unrelated reasons
+               // (skippedTestsForSSL).
+               ServiceHTTPSTargetPort: 9120,
        })
        svc := s.GetDataplaneService()
 
diff --git a/test/e2e/framework/manifests/apisix.yaml 
b/test/e2e/framework/manifests/apisix.yaml
index ad8be66f..12094c26 100644
--- a/test/e2e/framework/manifests/apisix.yaml
+++ b/test/e2e/framework/manifests/apisix.yaml
@@ -55,6 +55,11 @@ data:
           - 9100
           - addr: 9110
             tls: true
+          # TLS passthrough: the SNI is read out of the prereaded ClientHello
+          # and the stream reaches the backend untouched. A listen is either
+          # terminating or prereading, never both, so this needs its own port.
+          - addr: 9120
+            tls_passthrough: true
         udp:                        # UDP proxy port list
           - 9200
     discovery:
@@ -128,6 +133,9 @@ spec:
             - name: tls
               containerPort: 9110
               protocol: TCP
+            - name: tls-passthrough
+              containerPort: 9120
+              protocol: TCP
           volumeMounts:
             - name: config-writable
               mountPath: /usr/local/apisix/conf
@@ -164,7 +172,11 @@ spec:
     - port: {{ .ServiceHTTPSPort }}
       name: https
       protocol: TCP
-      targetPort: 9443
+      # Normally APISIX's HTTP ssl listen. The conformance run redirects it to
+      # the stream tls_passthrough listen instead: a Gateway API TLSRoute
+      # Passthrough listener is fixed at 443, and one port cannot serve both
+      # HTTP-over-TLS and an untouched stream.
+      targetPort: {{ .ServiceHTTPSTargetPort }}
     - port: 9180
       name: admin
       protocol: TCP
@@ -181,6 +193,10 @@ spec:
       port: 9110
       protocol: TCP
       targetPort: 9110
+    - name: tls-passthrough
+      port: 9120
+      protocol: TCP
+      targetPort: 9120
   selector:
     app.kubernetes.io/name: apisix 
   type: {{ .ServiceType | default "NodePort" }}
diff --git a/test/e2e/gatewayapi/tlsroute.go b/test/e2e/gatewayapi/tlsroute.go
index fb06be9c..514ccccc 100644
--- a/test/e2e/gatewayapi/tlsroute.go
+++ b/test/e2e/gatewayapi/tlsroute.go
@@ -24,7 +24,9 @@ import (
 
        . "github.com/onsi/ginkgo/v2"
        . "github.com/onsi/gomega"
+       "k8s.io/utils/ptr"
 
+       "github.com/apache/apisix-ingress-controller/test/e2e/framework"
        "github.com/apache/apisix-ingress-controller/test/e2e/scaffold"
 )
 
@@ -121,4 +123,87 @@ spec:
                                Should(ContainSubstring("EOF"), "should get EOF 
after deleting TLSRoute")
                })
        })
+
+       Context("TLSRoute Passthrough", func() {
+               // The certificate the e2e nginx serves, and the CA that signed 
it.
+               const backendSNI = "server.example.com"
+
+               var passthroughGateway = `
+apiVersion: gateway.networking.k8s.io/v1
+kind: Gateway
+metadata:
+  name: tls-passthrough-gateway
+spec:
+  gatewayClassName: %s
+  listeners:
+    - name: passthrough
+      protocol: TLS
+      # Must equal APISIX's physical stream_proxy listen configured with
+      # tls_passthrough (see the e2e apisix manifest): the controller runs with
+      # listener_port_match_mode=auto, so the port reaches the stream route as 
a
+      # server_port match and has to be one the data plane actually accepts on.
+      port: 9120
+      hostname: server.example.com
+      tls:
+        mode: Passthrough
+  infrastructure:
+    parametersRef:
+      group: apisix.apache.org
+      kind: GatewayProxy
+      name: apisix-proxy-config
+`
+
+               var passthroughRoute = `
+apiVersion: gateway.networking.k8s.io/v1
+kind: TLSRoute
+metadata:
+  name: tls-passthrough-route
+spec:
+  parentRefs:
+  - name: tls-passthrough-gateway
+    sectionName: passthrough
+  hostnames: ["server.example.com"]
+  rules:
+  - backendRefs:
+    - name: nginx
+      port: 443
+`
+
+               BeforeEach(func() {
+                       By("create GatewayProxy")
+                       
Expect(s.CreateResourceFromString(s.GetGatewayProxySpec())).NotTo(HaveOccurred(),
 "creating GatewayProxy")
+
+                       By("create GatewayClass")
+                       
Expect(s.CreateResourceFromString(s.GetGatewayClassYaml())).NotTo(HaveOccurred(),
 "creating GatewayClass")
+
+                       By("create Gateway with a Passthrough listener")
+                       
Expect(s.CreateResourceFromString(fmt.Sprintf(passthroughGateway, 
s.Namespace()))).NotTo(HaveOccurred(), "creating Gateway")
+
+                       By("deploy the TLS backend")
+                       s.DeployNginx(framework.NginxOptions{
+                               Namespace: s.Namespace(),
+                               Replicas:  ptr.To(int32(1)),
+                       })
+               })
+
+               It("forwards the stream to the backend that owns the 
certificate", func() {
+                       s.ResourceApplied("TLSRoute", "tls-passthrough-route", 
passthroughRoute, 1)
+
+                       // The client verifies the served chain against the 
backend's own CA.
+                       // The gateway holds no certificate for this listener - 
Passthrough
+                       // takes no certificateRefs - so a chain that validates 
here can only
+                       // have come from nginx, which is what passthrough 
means.
+                       s.RequestAssert(&scaffold.RequestAssert{
+                               Client: 
s.NewAPISIXClientWithTLSPassthrough(backendSNI, []byte(framework.TestCACert)),
+                               Method: http.MethodGet,
+                               Path:   "/",
+                               Checks: []scaffold.ResponseCheckFunc{
+                                       
scaffold.WithExpectedStatus(http.StatusOK),
+                                       
scaffold.WithExpectedBodyContains("Hello, World!"),
+                               },
+                               Timeout:  time.Minute * 3,
+                               Interval: time.Second * 2,
+                       })
+               })
+       })
 })
diff --git a/test/e2e/gatewayapi/udproute.go b/test/e2e/gatewayapi/udproute.go
index 477a2607..3841c1c9 100644
--- a/test/e2e/gatewayapi/udproute.go
+++ b/test/e2e/gatewayapi/udproute.go
@@ -74,19 +74,14 @@ spec:
                                NotTo(HaveOccurred(), "creating GatewayProxy")
 
                        // Create GatewayClass
+                       // Re-applying a fetched object would carry its 
resourceVersion, and
+                       // the controller writing status in between turns that 
into a Conflict.
                        gatewayClassName := s.Namespace()
-                       
Expect(s.CreateResourceFromString(s.GetGatewayClassYaml())).
-                               NotTo(HaveOccurred(), "creating GatewayClass")
-                       gcyaml, _ := s.GetResourceYaml("GatewayClass", 
gatewayClassName)
-                       s.ResourceApplied("GatewayClass", gatewayClassName, 
gcyaml, 1)
+                       s.ResourceApplied("GatewayClass", gatewayClassName, 
s.GetGatewayClassYaml(), 1)
 
                        // Create Gateway with UDP listener
                        gatewayName := s.Namespace()
-                       
Expect(s.CreateResourceFromString(fmt.Sprintf(udpGateway, gatewayName, 
gatewayClassName))).
-                               NotTo(HaveOccurred(), "creating Gateway")
-
-                       gwyaml, _ := s.GetResourceYaml("Gateway", gatewayName)
-                       s.ResourceApplied("Gateway", gatewayName, gwyaml, 1)
+                       s.ResourceApplied("Gateway", gatewayName, 
fmt.Sprintf(udpGateway, gatewayName, gatewayClassName), 1)
                })
 
                It("should route UDP traffic to backend service", func() {
diff --git a/test/e2e/scaffold/apisix_deployer.go 
b/test/e2e/scaffold/apisix_deployer.go
index a5cc84eb..3d5b3b5c 100644
--- a/test/e2e/scaffold/apisix_deployer.go
+++ b/test/e2e/scaffold/apisix_deployer.go
@@ -43,6 +43,11 @@ type APISIXDeployOptions struct {
        ServiceType      string
        ServiceHTTPPort  int
        ServiceHTTPSPort int
+       // ServiceHTTPSTargetPort is the container port the HTTPS service port
+       // forwards to: APISIX's HTTP ssl listen (9443) by default, or its 
stream
+       // tls_passthrough listen (9120). See the manifest for why the two 
cannot
+       // share a port.
+       ServiceHTTPSTargetPort int
 
        ConfigProvider string
        Replicas       *int
@@ -178,11 +183,12 @@ func (s *APISIXDeployer) AfterEach() {
 
 func (s *APISIXDeployer) DeployDataplane(deployOpts DeployDataplaneOptions) {
        opts := APISIXDeployOptions{
-               Namespace:        s.namespace,
-               AdminKey:         s.runtimeOpts.APISIXAdminAPIKey,
-               ServiceHTTPPort:  9080,
-               ServiceHTTPSPort: 9443,
-               Replicas:         ptr.To(1),
+               Namespace:              s.namespace,
+               AdminKey:               s.runtimeOpts.APISIXAdminAPIKey,
+               ServiceHTTPPort:        9080,
+               ServiceHTTPSPort:       9443,
+               ServiceHTTPSTargetPort: 9443,
+               Replicas:               ptr.To(1),
        }
 
        if deployOpts.Namespace != "" {
@@ -197,6 +203,9 @@ func (s *APISIXDeployer) DeployDataplane(deployOpts 
DeployDataplaneOptions) {
        if deployOpts.ServiceHTTPSPort != 0 {
                opts.ServiceHTTPSPort = deployOpts.ServiceHTTPSPort
        }
+       if deployOpts.ServiceHTTPSTargetPort != 0 {
+               opts.ServiceHTTPSTargetPort = deployOpts.ServiceHTTPSTargetPort
+       }
        if deployOpts.AdminKey != "" {
                opts.AdminKey = deployOpts.AdminKey
        }
@@ -409,10 +418,11 @@ func (s *APISIXDeployer) 
CreateAdditionalGatewayWithOptions(namePrefix string, o
 
        // Deploy dataplane for this additional gateway
        o := APISIXDeployOptions{
-               Namespace:        additionalNS,
-               AdminKey:         adminKey,
-               ServiceHTTPPort:  9080,
-               ServiceHTTPSPort: 9443,
+               Namespace:              additionalNS,
+               AdminKey:               adminKey,
+               ServiceHTTPPort:        9080,
+               ServiceHTTPSPort:       9443,
+               ServiceHTTPSTargetPort: 9443,
        }
        if opts.Namespace != "" {
                o.Namespace = opts.Namespace
@@ -426,6 +436,9 @@ func (s *APISIXDeployer) 
CreateAdditionalGatewayWithOptions(namePrefix string, o
        if opts.ServiceHTTPSPort != 0 {
                o.ServiceHTTPSPort = opts.ServiceHTTPSPort
        }
+       if opts.ServiceHTTPSTargetPort != 0 {
+               o.ServiceHTTPSTargetPort = opts.ServiceHTTPSTargetPort
+       }
        if opts.ProviderType != "" {
                if opts.ProviderType == framework.ProviderTypeAPISIX {
                        o.ConfigProvider = framework.ConfigProviderTypeEtcd
diff --git a/test/e2e/scaffold/apisix_prewarm.go 
b/test/e2e/scaffold/apisix_prewarm.go
index 6d1903a6..e1949736 100644
--- a/test/e2e/scaffold/apisix_prewarm.go
+++ b/test/e2e/scaffold/apisix_prewarm.go
@@ -166,13 +166,14 @@ func provisionDataplane(t *bgTestingT, env *pooledEnv, _ 
Options) (*corev1.Servi
        }
 
        deployOpts := APISIXDeployOptions{
-               Namespace:        env.namespace,
-               AdminKey:         env.adminKey,
-               ServiceName:      serviceName,
-               ServiceHTTPPort:  9080,
-               ServiceHTTPSPort: 9443,
-               ConfigProvider:   configProvider,
-               Replicas:         ptr.To(1),
+               Namespace:              env.namespace,
+               AdminKey:               env.adminKey,
+               ServiceName:            serviceName,
+               ServiceHTTPPort:        9080,
+               ServiceHTTPSPort:       9443,
+               ServiceHTTPSTargetPort: 9443,
+               ConfigProvider:         configProvider,
+               Replicas:               ptr.To(1),
        }
        buf := bytes.NewBuffer(nil)
        if err := framework.APISIXStandaloneTpl.Execute(buf, &deployOpts); err 
!= nil {
diff --git a/test/e2e/scaffold/deployer.go b/test/e2e/scaffold/deployer.go
index 77557828..3ee5d2ce 100644
--- a/test/e2e/scaffold/deployer.go
+++ b/test/e2e/scaffold/deployer.go
@@ -47,7 +47,10 @@ type DeployDataplaneOptions struct {
        SkipCreateTunnels bool
        ServiceHTTPPort   int
        ServiceHTTPSPort  int
-       Replicas          *int
-       AdminKey          string
-       ProviderType      string
+       // ServiceHTTPSTargetPort redirects the HTTPS service port at the data
+       // plane's stream tls_passthrough listen instead of its HTTP ssl listen.
+       ServiceHTTPSTargetPort int
+       Replicas               *int
+       AdminKey               string
+       ProviderType           string
 }
diff --git a/test/e2e/scaffold/scaffold.go b/test/e2e/scaffold/scaffold.go
index 231b71ad..6ce8356e 100644
--- a/test/e2e/scaffold/scaffold.go
+++ b/test/e2e/scaffold/scaffold.go
@@ -20,12 +20,15 @@ package scaffold
 import (
        "context"
        "crypto/tls"
+       "crypto/x509"
        "encoding/json"
        "fmt"
+       "net"
        "net/http"
        "net/url"
        "os"
        "strings"
+       "sync"
        "time"
 
        "github.com/api7/gopkg/pkg/log"
@@ -94,6 +97,9 @@ type Tunnels struct {
        HTTPS Tunnel
        TCP   Tunnel
        TLS   Tunnel
+       // TLSPassthrough reaches the stream listen that prereads the SNI and
+       // forwards the connection without terminating it.
+       TLSPassthrough Tunnel
 }
 
 func (t *Tunnels) Close() {
@@ -113,6 +119,10 @@ func (t *Tunnels) Close() {
                t.safeClose(t.TLS.Close)
                t.TLS = nil
        }
+       if t.TLSPassthrough != nil {
+               t.safeClose(t.TLSPassthrough.Close)
+               t.TLSPassthrough = nil
+       }
 }
 
 func (t *Tunnels) safeClose(close func()) {
@@ -300,6 +310,176 @@ func (s *Scaffold) NewAPISIXClientWithTCPProxy() 
*httpexpect.Expect {
        })
 }
 
+// tlsPassthroughPortName is the data plane Service port that forwards to the
+// APISIX stream listen configured with tls_passthrough.
+const tlsPassthroughPortName = "tls-passthrough"
+
+// NewAPISIXClientWithTLSPassthrough dials the data plane's TLS passthrough
+// stream listen with sni and verifies the served chain against caCert.
+//
+// Verifying is the point: the gateway holds no certificate for a passthrough
+// listener, so a chain that validates against the backend's own CA can only
+// have come from the backend itself.
+func (s *Scaffold) NewAPISIXClientWithTLSPassthrough(sni string, caCert 
[]byte) *httpexpect.Expect {
+       pool := x509.NewCertPool()
+       Expect(pool.AppendCertsFromPEM(caCert)).To(BeTrue(), "parsing CA 
certificate")
+
+       forward := s.newTLSPassthroughForward(&tls.Config{ServerName: sni, 
RootCAs: pool})
+
+       u := url.URL{
+               Scheme: apiv2.SchemeHTTPS,
+               Host:   forward.endpoint(),
+       }
+       return httpexpect.WithConfig(httpexpect.Config{
+               BaseURL: u.String(),
+               Client: &http.Client{
+                       Transport: &http.Transport{
+                               // Every request dials afresh, so a forwarder 
that died since the
+                               // last one is noticed and replaced rather than 
reused.
+                               DisableKeepAlives: true,
+                               DialTLSContext:    forward.dialTLSContext,
+                       },
+                       CheckRedirect: func(req *http.Request, via 
[]*http.Request) error {
+                               return http.ErrUseLastResponse
+                       },
+               },
+               Reporter: httpexpect.NewAssertReporter(
+                       httpexpect.NewAssertReporter(s.GinkgoT),
+               ),
+       })
+}
+
+// tlsPassthroughForward owns the port-forward to the data plane's
+// tls_passthrough stream port and replaces it whenever it stops working.
+//
+// Two things make this port different from the others the scaffold forwards.
+//
+// A tls_passthrough listen prereads the ClientHello to pick a stream route, 
and
+// resets a connection no route matches. The port-forward treats that reset as
+// fatal and exits, taking the local listener with it - every later dial then
+// gets "connection refused" rather than anything describing the cause. A
+// handshake that succeeded once does not rule this out: the data plane workers
+// load a new stream route independently, so the next connection can still land
+// on one that has not. So the forward is verified with a real handshake for 
the
+// SNI, never a bare TCP dial, and any failed dial rebuilds it instead of
+// leaving every later retry to hit a forwarder that is already gone.
+//
+// It is also forwarded on demand rather than alongside the HTTP tunnels when 
the
+// data plane is deployed: APISIX opens its stream listens later than its HTTP
+// ones, and only this one spec dials this port, so the rest of the suite would
+// pay for a forward it never uses.
+type tlsPassthroughForward struct {
+       s         *Scaffold
+       opts      k8s.KubectlOptions
+       service   string
+       port      int
+       tlsConfig *tls.Config
+
+       mu     sync.Mutex
+       tunnel *k8s.Tunnel
+}
+
+func (s *Scaffold) newTLSPassthroughForward(tlsConfig *tls.Config) 
*tlsPassthroughForward {
+       svc := s.dataplaneService
+       Expect(svc).NotTo(BeNil(), "data plane service")
+
+       var port int
+       for _, p := range svc.Spec.Ports {
+               if p.Name == tlsPassthroughPortName {
+                       port = int(p.Port)
+               }
+       }
+       Expect(port).NotTo(BeZero(), "data plane service has no %s port", 
tlsPassthroughPortName)
+
+       // Copy the scaffold's options rather than building fresh ones: a run 
that
+       // configures a kubeconfig or context would otherwise be ignored here 
while
+       // every other dataplane tunnel honours it.
+       f := &tlsPassthroughForward{
+               s:         s,
+               opts:      *s.kubectlOptions,
+               service:   svc.Name,
+               port:      port,
+               tlsConfig: tlsConfig,
+       }
+       f.opts.Namespace = svc.Namespace
+
+       Eventually(func() error {
+               t, err := f.open()
+               if err != nil {
+                       return err
+               }
+               f.replace(t)
+               return nil
+       }).WithTimeout(time.Minute*2).WithPolling(time.Second*2).
+               Should(Succeed(), "forwarding the tls passthrough port")
+       return f
+}
+
+// open forwards the port and proves the forward with a handshake, since
+// ForwardPortE returns before the forward has proxied anything.
+func (f *tlsPassthroughForward) open() (*k8s.Tunnel, error) {
+       t := k8s.NewTunnel(&f.opts, k8s.ResourceTypeService, f.service, 0, 
f.port)
+       if err := t.ForwardPortE(f.s.t); err != nil {
+               return nil, err
+       }
+       conn, err := f.handshake(context.Background(), t.Endpoint())
+       if err != nil {
+               t.Close()
+               return nil, err
+       }
+       _ = conn.Close()
+       return t, nil
+}
+
+func (f *tlsPassthroughForward) handshake(ctx context.Context, endpoint 
string) (*tls.Conn, error) {
+       d := &tls.Dialer{
+               NetDialer: &net.Dialer{Timeout: 10 * time.Second},
+               Config:    f.tlsConfig.Clone(),
+       }
+       conn, err := d.DialContext(ctx, "tcp", endpoint)
+       if err != nil {
+               return nil, err
+       }
+       return conn.(*tls.Conn), nil
+}
+
+// replace installs t as the current forward and closes the one it supersedes.
+// Tunnels.Close only knows about the tunnel stored there, so a superseded
+// forward would otherwise keep running until the process exits.
+func (f *tlsPassthroughForward) replace(t *k8s.Tunnel) {
+       if f.tunnel != nil {
+               f.tunnel.Close()
+       } else if previous := f.s.apisixTunnels.TLSPassthrough; previous != nil 
{
+               previous.Close()
+       }
+       f.tunnel = t
+       f.s.apisixTunnels.TLSPassthrough = t
+}
+
+func (f *tlsPassthroughForward) endpoint() string {
+       f.mu.Lock()
+       defer f.mu.Unlock()
+       return f.tunnel.Endpoint()
+}
+
+// dialTLSContext ignores addr: the request URL names whichever local port the
+// forward had when the client was built, and that port changes on a rebuild.
+func (f *tlsPassthroughForward) dialTLSContext(ctx context.Context, _, _ 
string) (net.Conn, error) {
+       f.mu.Lock()
+       defer f.mu.Unlock()
+
+       conn, err := f.handshake(ctx, f.tunnel.Endpoint())
+       if err == nil {
+               return conn, nil
+       }
+       t, rebuildErr := f.open()
+       if rebuildErr != nil {
+               return nil, fmt.Errorf("%w; rebuilding the tls passthrough 
forward: %v", err, rebuildErr)
+       }
+       f.replace(t)
+       return f.handshake(ctx, t.Endpoint())
+}
+
 func (s *Scaffold) NewAPISIXClientWithTLSProxy(host string) *httpexpect.Expect 
{
        u := url.URL{
                Scheme: apiv2.SchemeHTTPS,
@@ -498,6 +678,9 @@ func (s *Scaffold) createDataplaneTunnels(
        }
        tunnels.TLS = tlsTunnel
 
+       // The TLS passthrough tunnel is deliberately not forwarded here. See
+       // tlsPassthroughForward.
+
        return tunnels, nil
 }
 

Reply via email to