From fccc9abb70c2bec72cfeeed454a126e99f02bc9b Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Sun, 20 Sep 2026 08:22:01 +0800 Subject: [PATCH 1/6] feat: serve TLSRoute in Passthrough mode The stream proxy can now forward a TLS stream to the upstream untouched while still picking that upstream from the SNI, which it prereads from the ClientHello rather than learning from a handshake it performed itself (apache/apisix#13912). That is exactly what Gateway API asks of a listener in Passthrough mode, and until now the only thing the stream subsystem could not do - routing by SNI implied terminating. A Passthrough listener therefore behaved as Terminate: the translator never looked at tls.mode, so the gateway decrypted a stream the backend was supposed to own, and the handshake failed against a certificate the gateway does not have. - TLSRoute now reads the tls.mode of the listeners it attached to and sets tls_passthrough on the stream routes bound to a Passthrough port. Its controller populates tctx.Listeners for that, as the TCPRoute and UDPRoute ones already did. - Every matched listener on a port has to agree on the mode. A physical stream listen is either terminating or prereading, never both, so listeners that disagree fall back to terminating rather than to a guess. Within one Gateway such a port is already ProtocolConflict and attaches no routes at all. Two adjacent defects in the same code, both of which passthrough would have made visible: - every hostname produced its own StreamRoute under one name, so they all collapsed onto a single id and only the last survived. One StreamRoute now carries them all - sni for a single hostname, snis beyond that, never both, which APISIX rejects. - the StreamRoutes carried no server_port, so several listener ports fell onto one route and shared an id (the TCPRoute/UDPRoute side of this was #2802). TLSRoute now goes through the same per-port fan-out, gated by the same listener_port_match_mode. A TLSRoute with no hostnames used to produce no StreamRoute at all - attached, but unserved. It now falls back to the listener hostnames and, failing those, to the catch-all "*". Conformance: the four TLSRoute tests pinned to Passthrough are no longer skipped. Their Gateway listener is fixed at port 443, so the conformance data plane points its 443 service port at the stream tls_passthrough listen instead of the HTTP ssl listen - one port cannot serve both, and the HTTPRoute tests that would want HTTP-over-TLS there are skipped for unrelated SAN reasons. E2E adds a Passthrough spec that verifies the served chain against the backend's own CA: the gateway holds no certificate for a Passthrough listener, so a chain that validates there can only have come from the backend. Requires ADC to carry the new fields (api7/adc#618). --- api/adc/types.go | 9 +- api/adc/zz_generated.deepcopy.go | 10 + docs/en/latest/concepts/gateway-api.md | 2 +- internal/adc/translator/l4route_test.go | 15 +- internal/adc/translator/tcproute.go | 34 ++-- internal/adc/translator/tlsroute.go | 94 ++++++++- internal/adc/translator/tlsroute_test.go | 223 +++++++++++++++++++++ internal/controller/tlsroute_controller.go | 9 + test/conformance/conformance_test.go | 12 -- test/conformance/suite_test.go | 7 + test/e2e/framework/manifests/apisix.yaml | 18 +- test/e2e/gatewayapi/tlsroute.go | 86 ++++++++ test/e2e/scaffold/apisix_deployer.go | 28 ++- test/e2e/scaffold/apisix_prewarm.go | 15 +- test/e2e/scaffold/deployer.go | 9 +- test/e2e/scaffold/scaffold.go | 71 ++++++- 16 files changed, 577 insertions(+), 65 deletions(-) create mode 100644 internal/adc/translator/tlsroute_test.go diff --git a/api/adc/types.go b/api/adc/types.go index f7adae1cd..df58231f1 100644 --- a/api/adc/types.go +++ b/api/adc/types.go @@ -170,7 +170,14 @@ 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. APISIX only consults it on a stream + // listen configured with both tls and tls_passthrough. + 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 fc11631fc..6e413a2fb 100644 --- a/api/adc/zz_generated.deepcopy.go +++ b/api/adc/zz_generated.deepcopy.go @@ -567,6 +567,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/docs/en/latest/concepts/gateway-api.md b/docs/en/latest/concepts/gateway-api.md index 27f003580..ca763ab3e 100644 --- a/docs/en/latest/concepts/gateway-api.md +++ b/docs/en/latest/concepts/gateway-api.md @@ -85,6 +85,6 @@ The fields below are specified in the Gateway API specification but are either p | `spec.listeners[].port` | Not supported* | The configuration is required but ignored. This is due to limitations in the data plane: it cannot dynamically open new ports. Since the Ingress Controller does not manage the data plane deployment, it cannot automatically update the configuration or restart the data plane to apply port changes. | | `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: listeners that disagree on `tls.mode` for one port are reported `Accepted=False` with `ProtocolConflict`. | | `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 62d0ca038..2bb9fc736 100644 --- a/internal/adc/translator/l4route_test.go +++ b/internal/adc/translator/l4route_test.go @@ -251,13 +251,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/tcproute.go b/internal/adc/translator/tcproute.go index 7de4a67ec..e08e09ee7 100644 --- a/internal/adc/translator/tcproute.go +++ b/internal/adc/translator/tcproute.go @@ -54,6 +54,26 @@ func listenerPortSet(tctx *provider.TranslateContext) map[int32]struct{} { return portSet } +// l4StreamRoutePorts returns the listener ports to emit one StreamRoute for +// each, or the single sentinel 0 meaning one StreamRoute with no server_port +// match at all. See buildL4StreamRoutes for why the injection is opt-in. +func (t *Translator) l4StreamRoutePorts(tctx *provider.TranslateContext) []int32 { + var ports []int32 + if portSet := listenerPortSet(tctx); t.shouldInjectServerPortVars(tctx.HasExplicitListenerMatch, portSet) { + ports = make([]int32, 0, len(portSet)) + for port := range portSet { + ports = append(ports, port) + } + sort.Slice(ports, func(i, j int) bool { return ports[i] < ports[j] }) + } + if len(ports) == 0 { + // No server_port isolation: a single StreamRoute that matches all + // connections on the stream listener, as before. + return []int32{0} + } + return ports +} + // buildL4StreamRoutes builds the StreamRoutes for one L4 route rule. // // A StreamRoute without a server_port match matches every connection on any @@ -68,19 +88,7 @@ func listenerPortSet(tctx *provider.TranslateContext) map[int32]struct{} { // or more than one listener port). When it is not injected we keep the previous // single portless StreamRoute, preserving backward compatibility. func (t *Translator) buildL4StreamRoutes(tctx *provider.TranslateContext, namespace, name string, ruleIndex int, typ, routeKind string, labels map[string]string) []*adctypes.StreamRoute { - var ports []int32 - if portSet := listenerPortSet(tctx); t.shouldInjectServerPortVars(tctx.HasExplicitListenerMatch, portSet) { - ports = make([]int32, 0, len(portSet)) - for port := range portSet { - ports = append(ports, port) - } - sort.Slice(ports, func(i, j int) bool { return ports[i] < ports[j] }) - } - if len(ports) == 0 { - // No server_port isolation: a single StreamRoute that matches all - // connections on the stream listener, as before. - ports = []int32{0} - } + ports := t.l4StreamRoutePorts(tctx) streamRoutes := make([]*adctypes.StreamRoute, 0, len(ports)) for _, port := range ports { streamRoute := adctypes.NewDefaultStreamRoute() diff --git a/internal/adc/translator/tlsroute.go b/internal/adc/translator/tlsroute.go index 8d1fd0a6f..659a91664 100644 --- a/internal/adc/translator/tlsroute.go +++ b/internal/adc/translator/tlsroute.go @@ -20,6 +20,7 @@ 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" @@ -34,10 +35,7 @@ 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)) - } + snis := tlsRouteSNIs(tctx, tlsRoute) for ruleIndex, rule := range rules { service := adctypes.NewDefaultService() service.Labels = labels @@ -143,16 +141,33 @@ func (t *Translator) TranslateTLSRoute(tctx *provider.TranslateContext, tlsRoute } } - for _, host := range hosts { + for _, port := range t.l4StreamRoutePorts(tctx) { streamRoute := adctypes.NewDefaultStreamRoute() - streamRouteName := adctypes.ComposeStreamRouteName(tlsRoute.Namespace, tlsRoute.Name, fmt.Sprintf("%d", ruleIndex), "TLS") + ruleKey := fmt.Sprintf("%d", ruleIndex) + if port != 0 { + // Include the port in the name key so multiple listeners produce + // distinct StreamRoute names/IDs instead of colliding. + ruleKey = fmt.Sprintf("%d-%d", ruleIndex, port) + streamRoute.ServerPort = port + } + streamRouteName := adctypes.ComposeStreamRouteName(tlsRoute.Namespace, tlsRoute.Name, ruleKey, "TLS") streamRoute.Name = streamRouteName streamRoute.ID = id.GenID(streamRouteName) - 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, port) { + streamRoute.TLSPassthrough = ptr.To(true) + } streamRoute.Labels = labels // Attach L4RoutePolicy plugins at the stream_route level: the APISIX stream proxy - // applies plugins from the stream_route, not from the service. With multiple SNIs - // each stream_route carries its own copy of the plugins. + // applies plugins from the stream_route, not from the service. With multiple + // listener ports each stream_route carries its own copy of the plugins. streamRoute.Plugins = make(adctypes.Plugins) t.AttachL4RoutePolicyPlugins(tctx.L4RoutePolicies, tlsRoute.Namespace, tlsRoute.Name, "TLSRoute", streamRoute.Plugins, tctx.Secrets) service.StreamRoutes = append(service.StreamRoutes, streamRoute) @@ -162,3 +177,64 @@ func (t *Translator) TranslateTLSRoute(tctx *provider.TranslateContext, tlsRoute } return result, nil } + +// tlsRouteSNIs returns the SNIs the route's stream routes match on. +// +// A TLSRoute without hostnames matches everything its listeners accept, so it +// falls back to the matched listener hostnames and, when those carry none +// 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 tlsRouteSNIs(tctx *provider.TranslateContext, tlsRoute *gatewayv1.TLSRoute) []string { + if len(tlsRoute.Spec.Hostnames) > 0 { + snis := make([]string, 0, len(tlsRoute.Spec.Hostnames)) + for _, hostname := range tlsRoute.Spec.Hostnames { + snis = append(snis, string(hostname)) + } + return snis + } + + snis := make([]string, 0, len(tctx.Listeners)) + seen := make(map[string]struct{}, len(tctx.Listeners)) + for _, listener := range tctx.Listeners { + if listener.Hostname == nil || *listener.Hostname == "" { + continue + } + hostname := string(*listener.Hostname) + if _, ok := seen[hostname]; ok { + continue + } + seen[hostname] = struct{}{} + snis = append(snis, hostname) + } + if len(snis) == 0 { + return []string{"*"} + } + 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 matched TLS listener on the port has to agree. Within one Gateway a +// port carrying both modes is already reported ProtocolConflict and attaches +// no routes; across Gateways the combination is unrepresentable, since the +// physical stream listen has a single mode - so the terminating behaviour wins +// rather than a guess. +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 000000000..a3fd393dc --- /dev/null +++ b/internal/adc/translator/tlsroute_test.go @@ -0,0 +1,223 @@ +// 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 TestTranslateTLSRouteServerPort(t *testing.T) { + passthrough := gatewayv1.TLSModePassthrough + + t.Run("each listener port gets its own stream route", func(t *testing.T) { + result := translateTLSRoute(t, []gatewayv1.Listener{ + tlsModeListener("a", 9110, &passthrough, ""), + tlsModeListener("b", 9120, &passthrough, ""), + }, false, "example.com") + + // Regression: without a server_port match both listeners' traffic fell + // onto one StreamRoute, and the two shared a name, hence an id. + streamRoutes := result.Services[0].StreamRoutes + require.Len(t, streamRoutes, 2) + assert.Equal(t, int32(9110), streamRoutes[0].ServerPort) + assert.Equal(t, int32(9120), streamRoutes[1].ServerPort) + assert.NotEqual(t, streamRoutes[0].ID, streamRoutes[1].ID) + assert.NotEqual(t, streamRoutes[0].Name, streamRoutes[1].Name) + }) + + t.Run("a single listener without explicit targeting stays portless", func(t *testing.T) { + result := translateTLSRoute(t, []gatewayv1.Listener{ + tlsModeListener("a", 9110, &passthrough, ""), + }, false, "example.com") + + streamRoutes := result.Services[0].StreamRoutes + require.Len(t, streamRoutes, 1) + assert.Zero(t, streamRoutes[0].ServerPort) + }) +} diff --git a/internal/controller/tlsroute_controller.go b/internal/controller/tlsroute_controller.go index 5bbd5a591..2d93ffc3e 100644 --- a/internal/controller/tlsroute_controller.go +++ b/internal/controller/tlsroute_controller.go @@ -317,6 +317,15 @@ func (r *TLSRouteReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c acceptStatus.status = false acceptStatus.msg = err.Error() } + // Populate the matched listeners so the translator can derive the + // 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 { + tctx.Listeners = appendListeners(tctx.Listeners, *gateway.Listener) + } + tctx.HasExplicitListenerMatch = tctx.HasExplicitListenerMatch || gateway.ExplicitListenerMatch } var backendRefErr error diff --git a/test/conformance/conformance_test.go b/test/conformance/conformance_test.go index cf606fc60..86b2df310 100644 --- a/test/conformance/conformance_test.go +++ b/test/conformance/conformance_test.go @@ -31,17 +31,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{ @@ -77,7 +66,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...) opts.Implementation = conformancev1.Implementation{ Organization: "APISIX", diff --git a/test/conformance/suite_test.go b/test/conformance/suite_test.go index e7ab91b11..64783c31f 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 302891948..d36074f59 100644 --- a/test/e2e/framework/manifests/apisix.yaml +++ b/test/e2e/framework/manifests/apisix.yaml @@ -49,6 +49,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: @@ -119,6 +124,9 @@ spec: - name: tls containerPort: 9110 protocol: TCP + - name: tls-passthrough + containerPort: 9120 + protocol: TCP volumeMounts: - name: config-writable mountPath: /usr/local/apisix/conf @@ -151,7 +159,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 @@ -168,6 +180,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 08d4e4a36..a6361ac82 100644 --- a/test/e2e/gatewayapi/tlsroute.go +++ b/test/e2e/gatewayapi/tlsroute.go @@ -20,10 +20,13 @@ package gatewayapi import ( "fmt" "net/http" + "time" . "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" ) @@ -114,4 +117,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/scaffold/apisix_deployer.go b/test/e2e/scaffold/apisix_deployer.go index aa6d8fad3..9170e587f 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 @@ -175,11 +180,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 != "" { @@ -194,6 +200,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 } @@ -410,10 +419,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 diff --git a/test/e2e/scaffold/apisix_prewarm.go b/test/e2e/scaffold/apisix_prewarm.go index 030917365..7cce59dea 100644 --- a/test/e2e/scaffold/apisix_prewarm.go +++ b/test/e2e/scaffold/apisix_prewarm.go @@ -165,13 +165,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 d9ddb2f60..ad4127685 100644 --- a/test/e2e/scaffold/deployer.go +++ b/test/e2e/scaffold/deployer.go @@ -45,7 +45,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 41ac85c74..6961481c8 100644 --- a/test/e2e/scaffold/scaffold.go +++ b/test/e2e/scaffold/scaffold.go @@ -20,6 +20,7 @@ package scaffold import ( "context" "crypto/tls" + "crypto/x509" "fmt" "net/http" "net/url" @@ -91,6 +92,9 @@ type Tunnels struct { TCP Tunnel HTTP2 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() { @@ -114,6 +118,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()) { @@ -301,6 +309,45 @@ 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 { + Expect(s.apisixTunnels.TLSPassthrough).NotTo(BeNil(), "tls passthrough tunnel") + + pool := x509.NewCertPool() + Expect(pool.AppendCertsFromPEM(caCert)).To(BeTrue(), "parsing CA certificate") + + u := url.URL{ + Scheme: apiv2.SchemeHTTPS, + Host: s.apisixTunnels.TLSPassthrough.Endpoint(), + } + return httpexpect.WithConfig(httpexpect.Config{ + BaseURL: u.String(), + Client: &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: pool, + ServerName: sni, + }, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + Reporter: httpexpect.NewAssertReporter( + httpexpect.NewAssertReporter(s.GinkgoT), + ), + }) +} + func (s *Scaffold) NewAPISIXClientWithTLSProxy(host string) *httpexpect.Expect { u := url.URL{ Scheme: apiv2.SchemeHTTPS, @@ -412,11 +459,12 @@ func (s *Scaffold) createDataplaneTunnels( serviceName string, ) (*Tunnels, error) { var ( - httpPort int - httpsPort int - tcpPort int - http2Port int - tlsPort int + httpPort int + httpsPort int + tcpPort int + http2Port int + tlsPort int + tlsPassthroughPort int ) for _, port := range svc.Spec.Ports { @@ -431,6 +479,8 @@ func (s *Scaffold) createDataplaneTunnels( http2Port = int(port.Port) case apiv2.SchemeTLS: tlsPort = int(port.Port) + case tlsPassthroughPortName: + tlsPassthroughPort = int(port.Port) } } @@ -474,6 +524,17 @@ func (s *Scaffold) createDataplaneTunnels( tunnels.HTTP2 = http2Tunnel } + // Absent on a gateway deployed from an older manifest revision; the specs + // that need it assert on the tunnel being there. + if tlsPassthroughPort != 0 { + tlsPassthroughTunnel := k8s.NewTunnel(kubectlOpts, k8s.ResourceTypeService, serviceName, + 0, tlsPassthroughPort) + if err := tlsPassthroughTunnel.ForwardPortE(s.t); err != nil { + return nil, err + } + tunnels.TLSPassthrough = tlsPassthroughTunnel + } + return tunnels, nil } From c586e8622c9387eef7120065128cac29c99632a6 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Sun, 20 Sep 2026 08:22:01 +0800 Subject: [PATCH 2/6] fix: intersect TLSRoute hostnames with the listener hostname A TLSRoute's hostnames become the SNIs its stream routes match on, and they were used verbatim. Gateway API defines the effective hostnames as the intersection with the listener hostname, so a route attached to a narrower listener served names that listener never accepted. On one shared stream listen that is not merely over-serving. Every Gateway resolves to the same data plane, so an over-broad SNI takes that name from the route whose listener does accept it: a route with "*.example.com" attached to an "abc.example.com" listener answered every *.example.com connection, including the ones a sibling Gateway's route was there to serve. HTTPRoute has narrowed its hostnames this way since it was written - filterHostnames plus getMinimumHostnameIntersection. Both now share intersectRouteHostnames, and the TLSRoute reconciler applies it exactly as the HTTPRoute one does, translating the narrowed copy and reporting NoMatchingListenerHostname when nothing intersects. The Gateway API conformance test TLSRouteHostnameIntersection is what surfaced this; with the fix its intersections pass. --- internal/controller/listener_utils_test.go | 92 ++++++++++++++++++++++ internal/controller/tlsroute_controller.go | 16 +++- internal/controller/utils.go | 60 +++++++++----- 3 files changed, 149 insertions(+), 19 deletions(-) diff --git a/internal/controller/listener_utils_test.go b/internal/controller/listener_utils_test.go index 9fa4cc9ab..3862b11ef 100644 --- a/internal/controller/listener_utils_test.go +++ b/internal/controller/listener_utils_test.go @@ -21,6 +21,7 @@ import ( "testing" "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) @@ -115,3 +116,94 @@ func TestGetMinimumHostnameIntersectionUsesMatchedListeners(t *testing.T) { require.Equal(t, gatewayv1.Hostname("b.example.com"), getMinimumHostnameIntersection(whole, "b.example.com")) } + +// 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 { + require.ErrorIs(t, err, ErrNoMatchingListenerHostname) + return + } + require.NoError(t, err) + require.Equal(t, tc.want, filtered.Spec.Hostnames) + }) + } +} diff --git a/internal/controller/tlsroute_controller.go b/internal/controller/tlsroute_controller.go index 2d93ffc3e..955f7e542 100644 --- a/internal/controller/tlsroute_controller.go +++ b/internal/controller/tlsroute_controller.go @@ -349,6 +349,17 @@ 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() + } + tr.Status.Parents = make([]gatewayv1.RouteParentStatus, 0, len(gateways)) for _, gateway := range gateways { parentStatus := gatewayv1.RouteParentStatus{} @@ -377,8 +388,11 @@ 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 { routeToUpdate := tr + if filteredTLSRoute != nil { + routeToUpdate = filteredTLSRoute + } if err := r.Provider.Update(ctx, tctx, routeToUpdate); err != nil { return ctrl.Result{}, err } diff --git a/internal/controller/utils.go b/internal/controller/utils.go index 3b7e31fdc..b09058190 100644 --- a/internal/controller/utils.go +++ b/internal/controller/utils.go @@ -1409,30 +1409,54 @@ 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) + hostnames, err := intersectRouteHostnames(gateways, httpRoute.Spec.Hostnames) + if err != nil { + return httpRoute, err + } + httpRoute.Spec.Hostnames = hostnames + return httpRoute, nil +} - // 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 { +// 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 +} + +// 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 smallest +// intersection with a listener hostname, and a route that intersects with none +// is ErrNoMatchingListenerHostname. +func intersectRouteHostnames(gateways []RouteParentRefContext, routeHostnames []gatewayv1.Hostname) ([]gatewayv1.Hostname, error) { + if len(routeHostnames) == 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 + return routeHostnames, nil } + return hostnames, nil } - httpRoute.Spec.Hostnames = filteredHostnames - return httpRoute, nil + filteredHostnames := make([]gatewayv1.Hostname, 0, len(routeHostnames)) + for _, hostname := range routeHostnames { + if hostnameMatching := getMinimumHostnameIntersection(gateways, hostname); hostnameMatching != "" { + filteredHostnames = append(filteredHostnames, hostnameMatching) + } + } + if len(filteredHostnames) == 0 { + return nil, ErrNoMatchingListenerHostname + } + return filteredHostnames, nil } // getUnionOfGatewayHostnames returns the union of the hostnames specified in all supported gateways From 2a425aff87b54a28d42ba4feef2d5b2ad4bcae94 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Sun, 20 Sep 2026 08:22:01 +0800 Subject: [PATCH 3/6] test: skip TLSRouteHostnameIntersection as a known architectural gap One assertion in it cannot hold here, and not for want of translating correctly. 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 that connection. Which Gateway a connection was addressed to is not on the wire, so nothing is left to discriminate on. This is the same limitation HTTPRouteMultipleGateways is already skipped for, and it sits beside it. Every other assertion in the test passes, including the hostname intersections themselves. --- test/conformance/conformance_test.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/test/conformance/conformance_test.go b/test/conformance/conformance_test.go index 86b2df310..223a0a73c 100644 --- a/test/conformance/conformance_test.go +++ b/test/conformance/conformance_test.go @@ -58,6 +58,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) { From 9b5b991b3b6824135be64f2ee3d83fac69839308 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Sun, 20 Sep 2026 08:28:07 +0800 Subject: [PATCH 4/6] test: declare the TLSRoute gap on the API7 gateway The APISIX suite stops skipping the TLSRoute Passthrough tests in this series, because APISIX can serve them. The API7 gateway cannot: its stream_route schema carries `sni` only, under additionalProperties = false, and has no tls_passthrough - it predates apache/apisix#13912. A stream route carrying either `snis` or `tls_passthrough` is rejected outright. Those four tests were never skipped here and have been failing unnoticed behind continue-on-error. Skipping them with the reason recorded says what is actually missing, and they come back as soon as the gateway carries the fields. --- test/conformance/api7ee/conformance_test.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/conformance/api7ee/conformance_test.go b/test/conformance/api7ee/conformance_test.go index a9572a9f2..f9aade686 100644 --- a/test/conformance/api7ee/conformance_test.go +++ b/test/conformance/api7ee/conformance_test.go @@ -31,6 +31,22 @@ var skippedTestsForSSL = []string{ tests.HTTPRouteRedirectPortAndScheme.ShortName, } +// The API7 gateway's stream_route schema carries `sni` only, under +// additionalProperties = false, and has no tls_passthrough - it predates +// apache/apisix#13912. A stream route carrying either `snis` or +// `tls_passthrough` is rejected outright, so a TLSRoute needs its own gateway +// support before these can run here. The APISIX provider runs them already; see +// test/conformance/conformance_test.go. +var skippedTestsForGatewaySchema = []string{ + // Pinned to mode: Passthrough, which the gateway cannot serve. + tests.TLSRouteSimpleSameNamespace.ShortName, + tests.TLSRouteInvalidBackendRefNonexistent.ShortName, + tests.TLSRouteInvalidBackendRefUnknownKind.ShortName, + // Passthrough as well, and its routes carry several hostnames, which the + // translator emits as `snis`. + tests.TLSRouteHostnameIntersection.ShortName, +} + // TODO: HTTPRoute hostname intersection and listener hostname matching func TestGatewayAPIConformance(t *testing.T) { @@ -39,6 +55,7 @@ func TestGatewayAPIConformance(t *testing.T) { opts.CleanupBaseResources = true opts.GatewayClassName = gatewayClassName opts.SkipTests = append(opts.SkipTests, skippedTestsForSSL...) + opts.SkipTests = append(opts.SkipTests, skippedTestsForGatewaySchema...) opts.Implementation = conformancev1.Implementation{ Organization: "APISIX", Project: "apisix-ingress-controller", From f224f783d08c93333c590134c18cc0fe9e60741d Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Sun, 20 Sep 2026 09:08:05 +0800 Subject: [PATCH 5/6] ci: run the APISIX conformance job against adc:dev The four TLSRoute tests this series stops skipping fail on this job, and not in traffic - the routes never reach the data plane: Accepted condition set to Status False with Reason SyncFailed HTTP 400 {"code":"unrecognized_keys","keys":["tls_passthrough"], "path":["services",0,"stream_routes",0]} ADC learned `snis` and `tls_passthrough` in api7/adc#618, which no release carries yet. `kind-load-adc-image` pulls `adc:$(ADC_VERSION)` and retags it as `:dev`, so the Makefile default 0.29.0 is what ran. Both e2e workflows already set `ADC_VERSION: dev` at the workflow level; this job had it commented out, and in the "Build images" step env, where it could never have reached `kind-load-adc-image` anyway. Declared the same way as the siblings instead. --- .github/workflows/apisix-conformance-test.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/apisix-conformance-test.yml b/.github/workflows/apisix-conformance-test.yml index dbff2fba8..cd9bd03d9 100644 --- a/.github/workflows/apisix-conformance-test.yml +++ b/.github/workflows/apisix-conformance-test.yml @@ -32,6 +32,9 @@ concurrency: permissions: pull-requests: write +env: + ADC_VERSION: dev + jobs: conformance-test: env: @@ -70,7 +73,6 @@ jobs: ARCH: amd64 ENABLE_PROXY: "false" BASE_IMAGE_TAG: "debug" - # ADC_VERSION: "dev" run: | echo "building images..." make build-image From 6f9f3cae895b0da91f608521d3da86f27964723b Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Sun, 20 Sep 2026 09:11:11 +0800 Subject: [PATCH 6/6] test: skip the Passthrough e2e spec in API7EE mode The API7 gateway's stream_route schema carries `sni` only, under additionalProperties = false, and has no tls_passthrough, so the route the spec applies never reaches the data plane. The control plane rejects it first: PUT /apisix/admin/stream_routes/... 400 can not create a Stream Route to the HTTP Service Same gap the api7ee conformance suite now declares, so the spec declares it the same way rather than failing the job. The APISIX providers keep running it. --- test/e2e/gatewayapi/tlsroute.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/e2e/gatewayapi/tlsroute.go b/test/e2e/gatewayapi/tlsroute.go index a6361ac82..5aa5352f1 100644 --- a/test/e2e/gatewayapi/tlsroute.go +++ b/test/e2e/gatewayapi/tlsroute.go @@ -181,6 +181,11 @@ spec: }) It("forwards the stream to the backend that owns the certificate", func() { + if s.Deployer.Name() == framework.ProviderTypeAPI7EE { + // The API7 gateway's stream_route schema has no tls_passthrough and + // refuses unknown keys, so the route never reaches the data plane. + Skip("skipping test in API7EE mode") + } s.ResourceApplied("TLSRoute", "tls-passthrough-route", passthroughRoute, 1) // The client verifies the served chain against the backend's own CA.