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 52557e1f fix: pin a route to the scheme its listeners accept (#2864)
52557e1f is described below

commit 52557e1fe99d7ca5e758f46b0f2bb7ad7c535254
Author: AlinsRan <[email protected]>
AuthorDate: Wed Sep 9 19:43:40 2026 +0800

    fix: pin a route to the scheme its listeners accept (#2864)
---
 docs/en/latest/concepts/gateway-api.md           |  12 ++
 internal/adc/translator/grpcroute.go             |   4 +
 internal/adc/translator/grpcroute_test.go        |   9 +-
 internal/adc/translator/httproute.go             |   8 +
 internal/adc/translator/httproute_scheme_test.go | 178 +++++++++++++++++++++++
 internal/adc/translator/httproute_test.go        |   9 +-
 internal/adc/translator/translator.go            |  56 +++++++
 test/e2e/gatewayapi/httproute.go                 |  15 ++
 test/e2e/scaffold/adc.go                         |   5 +-
 9 files changed, 293 insertions(+), 3 deletions(-)

diff --git a/docs/en/latest/concepts/gateway-api.md 
b/docs/en/latest/concepts/gateway-api.md
index b42d6633..27dbd8a2 100644
--- a/docs/en/latest/concepts/gateway-api.md
+++ b/docs/en/latest/concepts/gateway-api.md
@@ -90,3 +90,15 @@ The fields below are specified in the Gateway API 
specification but are either p
 | `spec.listeners[].tls.mode`                          | Partially supported  
| `Terminate` is implemented; `Passthrough` is effectively unsupported for 
Gateway listeners.    |
 | `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`.                          
                 |
+
+## Listener protocol and the request scheme
+
+A route answers only the schemes its listeners accept. When every listener a 
route attached to is `HTTPS`, the route is pinned to the `https` scheme, so a 
plaintext request for the same hostname and path does not match it. When they 
are all `HTTP`, it is pinned to `http`. A route attached to both is pinned to 
neither, because it is meant to serve both.
+
+The predicate is evaluated against the connection APISIX accepted, so it holds 
regardless of the port mapping in front of the data plane. That is what 
distinguishes it from 
[`listener_port_match_mode`](../reference/configuration-file.md#listener-port-matching),
 which pins a route to a listener port and can only isolate protocols when the 
Gateway's declared ports are the ports APISIX listens on.
+
+`TLS`, `TCP` and `UDP` listeners carry the L4 route kinds, which have no 
request scheme. A route is left unpinned if any of its listeners uses one of 
these.
+
+The one deployment this does not fit is TLS terminated in front of APISIX, 
where the connection APISIX accepts is plaintext even though the client used 
HTTPS. Declare those listeners as `HTTP`, since the Gateway is not terminating 
TLS in that topology and the listener's `certificateRefs` would go unused.
+
+This narrows which requests reach a route but does not amount to full Listener 
Isolation, an Extended Gateway API feature that also covers hostname overlap 
between listeners on the same port. `GatewayHTTPListenerIsolation` is not 
claimed.
diff --git a/internal/adc/translator/grpcroute.go 
b/internal/adc/translator/grpcroute.go
index e3a1cad3..53cd591a 100644
--- a/internal/adc/translator/grpcroute.go
+++ b/internal/adc/translator/grpcroute.go
@@ -313,6 +313,10 @@ func (t *Translator) TranslateGRPCRoute(tctx 
*provider.TranslateContext, grpcRou
                        routes = append(routes, route)
                }
 
+               // A route answers only the schemes its listeners accept. See 
the HTTPRoute
+               // translator for why neither hostname matching nor server_port 
covers this.
+               t.pinRoutesToListenerScheme(tctx.Listeners, routes)
+
                // Hostname-less listener ports decide whether a server_port 
var is needed;
                // hostname listeners are isolated by host, not port. When it 
is added, match
                // on every targeted listener port so a route attached to both 
a hostname-less
diff --git a/internal/adc/translator/grpcroute_test.go 
b/internal/adc/translator/grpcroute_test.go
index 749ae19d..3aa8e7d3 100644
--- a/internal/adc/translator/grpcroute_test.go
+++ b/internal/adc/translator/grpcroute_test.go
@@ -192,7 +192,14 @@ func TestTranslateGRPCRouteServerPortVarsByMode(t 
*testing.T) {
                        got, err := translator.TranslateGRPCRoute(tctx, 
grpcRoute)
                        assert.NoError(t, err)
                        if assert.Len(t, got.Services, 1) && assert.Len(t, 
got.Services[0].Routes, 1) {
-                               assert.Equal(t, tt.expected, 
got.Services[0].Routes[0].Vars)
+                               // Every listener in this table is HTTP, so the 
route also carries the
+                               // scheme predicate. 
TestTranslateHTTPRouteSchemeVar covers that on its own.
+                               want := append(adctypes.Vars{{
+                                       {StrVal: "scheme"},
+                                       {StrVal: "=="},
+                                       {StrVal: "http"},
+                               }}, tt.expected...)
+                               assert.Equal(t, want, 
got.Services[0].Routes[0].Vars)
                        }
                })
        }
diff --git a/internal/adc/translator/httproute.go 
b/internal/adc/translator/httproute.go
index cba58c21..82f48256 100644
--- a/internal/adc/translator/httproute.go
+++ b/internal/adc/translator/httproute.go
@@ -742,6 +742,14 @@ func (t *Translator) TranslateHTTPRoute(tctx 
*provider.TranslateContext, httpRou
                        routes = append(routes, route)
                }
 
+               // A route answers only the schemes its listeners accept: one 
attached only to
+               // HTTPS listeners must not answer plaintext requests for the 
same host and
+               // path, and one attached only to HTTP listeners must not 
answer TLS ones.
+               // Nothing else enforces that: hostname matching cannot tell 
the two apart, and
+               // server_port only can when the Gateway's declared ports equal 
the ports
+               // APISIX listens on.
+               t.pinRoutesToListenerScheme(tctx.Listeners, routes)
+
                // Hostname-less listener ports decide whether a server_port 
var is needed;
                // hostname listeners are isolated by host, not port.
                listenerPorts := collectServerPortMatchPorts(tctx.Listeners)
diff --git a/internal/adc/translator/httproute_scheme_test.go 
b/internal/adc/translator/httproute_scheme_test.go
new file mode 100644
index 00000000..ee78bb46
--- /dev/null
+++ b/internal/adc/translator/httproute_scheme_test.go
@@ -0,0 +1,178 @@
+// 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"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+       "k8s.io/utils/ptr"
+       gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
+
+       adctypes "github.com/apache/apisix-ingress-controller/api/adc"
+       "github.com/apache/apisix-ingress-controller/internal/controller/config"
+       "github.com/apache/apisix-ingress-controller/internal/provider"
+)
+
+func schemeVar(scheme string) []adctypes.StringOrSlice {
+       return []adctypes.StringOrSlice{
+               {StrVal: "scheme"},
+               {StrVal: "=="},
+               {StrVal: scheme},
+       }
+}
+
+// A route answers only the schemes its listeners accept. $scheme is evaluated
+// against the connection APISIX accepted, so this holds whatever port mapping 
sits
+// in front of the data plane, and it is independent of 
listener_port_match_mode.
+func TestTranslateHTTPRouteSchemeVar(t *testing.T) {
+       pathMatchType := gatewayv1.PathMatchPathPrefix
+       pathValue := "/"
+
+       https := func(port gatewayv1.PortNumber, hostname *gatewayv1.Hostname) 
gatewayv1.Listener {
+               return gatewayv1.Listener{
+                       Name:     "https",
+                       Protocol: gatewayv1.HTTPSProtocolType,
+                       Port:     port,
+                       Hostname: hostname,
+               }
+       }
+       plain := func(port gatewayv1.PortNumber) gatewayv1.Listener {
+               return gatewayv1.Listener{
+                       Name:     "http",
+                       Protocol: gatewayv1.HTTPProtocolType,
+                       Port:     port,
+               }
+       }
+
+       tests := []struct {
+               name      string
+               mode      config.ListenerPortMatchMode
+               listeners []gatewayv1.Listener
+               // want is the scheme the route must be pinned to, or "" for no 
pinning.
+               want string
+       }{
+               {
+                       name:      "https listener pins https with the default 
mode",
+                       mode:      config.ListenerPortMatchModeOff,
+                       listeners: []gatewayv1.Listener{https(443, nil)},
+                       want:      "https",
+               },
+               {
+                       // The declared 443 need not be the port APISIX listens 
on, which is what
+                       // makes server_port unusable here and the scheme var 
necessary.
+                       name:      "https listener with a hostname is pinned 
too",
+                       mode:      config.ListenerPortMatchModeOff,
+                       listeners: []gatewayv1.Listener{https(443, 
ptr.To(gatewayv1.Hostname("secure.example")))},
+                       want:      "https",
+               },
+               {
+                       name:      "http listener pins http",
+                       mode:      config.ListenerPortMatchModeOff,
+                       listeners: []gatewayv1.Listener{plain(80)},
+                       want:      "http",
+               },
+               {
+                       name:      "several listeners of the same protocol 
still pin it",
+                       mode:      config.ListenerPortMatchModeOff,
+                       listeners: []gatewayv1.Listener{https(443, nil), 
https(8443, ptr.To(gatewayv1.Hostname("secure.example")))},
+                       want:      "https",
+               },
+               {
+                       name:      "a route attached to both protocols serves 
both",
+                       mode:      config.ListenerPortMatchModeOff,
+                       listeners: []gatewayv1.Listener{https(443, nil), 
plain(80)},
+                       want:      "",
+               },
+               {
+                       name:      "no listener means nothing to pin",
+                       mode:      config.ListenerPortMatchModeOff,
+                       listeners: nil,
+                       want:      "",
+               },
+               {
+                       name:      "auto mode does not change the scheme 
predicate",
+                       mode:      config.ListenerPortMatchModeAuto,
+                       listeners: []gatewayv1.Listener{https(9443, nil)},
+                       want:      "https",
+               },
+       }
+
+       for _, tt := range tests {
+               t.Run(tt.name, func(t *testing.T) {
+                       tctx := 
provider.NewDefaultTranslateContext(context.Background())
+                       tctx.Listeners = tt.listeners
+
+                       httpRoute := &gatewayv1.HTTPRoute{
+                               ObjectMeta: metav1.ObjectMeta{Name: "route", 
Namespace: "default"},
+                               Spec: gatewayv1.HTTPRouteSpec{
+                                       Rules: []gatewayv1.HTTPRouteRule{{
+                                               Matches: 
[]gatewayv1.HTTPRouteMatch{{
+                                                       Path: 
&gatewayv1.HTTPPathMatch{Type: &pathMatchType, Value: &pathValue},
+                                               }},
+                                       }},
+                               },
+                       }
+
+                       got, err := NewTranslator(logr.Discard(), 
tt.mode).TranslateHTTPRoute(tctx, httpRoute)
+                       assert.NoError(t, err)
+                       if !assert.Len(t, got.Services, 1) || !assert.Len(t, 
got.Services[0].Routes, 1) {
+                               return
+                       }
+                       vars := got.Services[0].Routes[0].Vars
+                       if tt.want != "" {
+                               assert.Contains(t, vars, schemeVar(tt.want),
+                                       "a route whose listeners agree on a 
scheme must be pinned to it")
+                               return
+                       }
+                       assert.NotContains(t, vars, schemeVar("http"))
+                       assert.NotContains(t, vars, schemeVar("https"),
+                               "a route whose listeners disagree must serve 
both schemes")
+               })
+       }
+}
+
+// The L4 protocols carry TLSRoute, TCPRoute and UDPRoute, which have no 
request
+// scheme. listenerScheme must refuse to pin those rather than guess, so that a
+// listener set containing one leaves the route alone.
+func TestListenerScheme(t *testing.T) {
+       listener := func(protocol gatewayv1.ProtocolType) gatewayv1.Listener {
+               return gatewayv1.Listener{Name: 
gatewayv1.SectionName(protocol), Protocol: protocol, Port: 443}
+       }
+
+       for name, tt := range map[string]struct {
+               listeners []gatewayv1.Listener
+               want      string
+       }{
+               "http":              
{[]gatewayv1.Listener{listener(gatewayv1.HTTPProtocolType)}, "http"},
+               "https":             
{[]gatewayv1.Listener{listener(gatewayv1.HTTPSProtocolType)}, "https"},
+               "tls":               
{[]gatewayv1.Listener{listener(gatewayv1.TLSProtocolType)}, ""},
+               "tcp":               
{[]gatewayv1.Listener{listener(gatewayv1.TCPProtocolType)}, ""},
+               "udp":               
{[]gatewayv1.Listener{listener(gatewayv1.UDPProtocolType)}, ""},
+               "https beside tls":  
{[]gatewayv1.Listener{listener(gatewayv1.HTTPSProtocolType), 
listener(gatewayv1.TLSProtocolType)}, ""},
+               "http beside https": 
{[]gatewayv1.Listener{listener(gatewayv1.HTTPProtocolType), 
listener(gatewayv1.HTTPSProtocolType)}, ""},
+               "none":              {nil, ""},
+       } {
+               t.Run(name, func(t *testing.T) {
+                       assert.Equal(t, tt.want, listenerScheme(tt.listeners))
+               })
+       }
+}
diff --git a/internal/adc/translator/httproute_test.go 
b/internal/adc/translator/httproute_test.go
index 5b985f96..e838676e 100644
--- a/internal/adc/translator/httproute_test.go
+++ b/internal/adc/translator/httproute_test.go
@@ -225,7 +225,14 @@ func TestTranslateHTTPRouteServerPortVarsByMode(t 
*testing.T) {
                        got, err := translator.TranslateHTTPRoute(tctx, 
httpRoute)
                        assert.NoError(t, err)
                        if assert.Len(t, got.Services, 1) && assert.Len(t, 
got.Services[0].Routes, 1) {
-                               assert.Equal(t, tt.expected, 
got.Services[0].Routes[0].Vars)
+                               // Every listener in this table is HTTP, so the 
route also carries the
+                               // scheme predicate. 
TestTranslateHTTPRouteSchemeVar covers that on its own.
+                               want := append(adctypes.Vars{{
+                                       {StrVal: "scheme"},
+                                       {StrVal: "=="},
+                                       {StrVal: "http"},
+                               }}, tt.expected...)
+                               assert.Equal(t, want, 
got.Services[0].Routes[0].Vars)
                        }
                })
        }
diff --git a/internal/adc/translator/translator.go 
b/internal/adc/translator/translator.go
index ac14ee9e..6a865043 100644
--- a/internal/adc/translator/translator.go
+++ b/internal/adc/translator/translator.go
@@ -22,6 +22,7 @@ import (
        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/config"
 )
 
@@ -85,6 +86,61 @@ func allListenerPorts(listeners []gatewayv1.Listener) 
map[int32]struct{} {
        return ports
 }
 
+// listenerScheme returns the request scheme shared by every listener the route
+// attached to, or "" when they disagree, when there are none, or when any of
+// them is a protocol that carries no request scheme.
+//
+// Only an unambiguous answer pins the route. A route attached to both an HTTP 
and
+// an HTTPS listener is meant to serve both, and a listener protocol that has 
no
+// scheme at all - TLS, TCP, UDP, which carry the L4 route kinds - leaves the
+// route alone rather than being guessed at.
+func listenerScheme(listeners []gatewayv1.Listener) string {
+       scheme := ""
+       for _, listener := range listeners {
+               var current string
+               switch listener.Protocol {
+               case gatewayv1.HTTPProtocolType:
+                       current = apiv2.SchemeHTTP
+               case gatewayv1.HTTPSProtocolType:
+                       current = apiv2.SchemeHTTPS
+               default:
+                       return ""
+               }
+               if scheme != "" && scheme != current {
+                       return ""
+               }
+               scheme = current
+       }
+       return scheme
+}
+
+// pinRoutesToListenerScheme pins the routes of one rule to the scheme their
+// listeners accept, when the listeners agree on one.
+func (t *Translator) pinRoutesToListenerScheme(listeners []gatewayv1.Listener, 
routes []*adctypes.Route) {
+       scheme := listenerScheme(listeners)
+       if scheme == "" {
+               return
+       }
+       for _, route := range routes {
+               addSchemeVar(route, scheme)
+       }
+}
+
+// addSchemeVar pins a route to the scheme of the connection APISIX accepted.
+//
+// Unlike server_port this holds whatever port mapping sits in front of the 
data
+// plane, because $scheme reflects the connection itself rather than a number 
the
+// Gateway declared. It is therefore independent of listener_port_match_mode,
+// which exists to pin a route to a listener port and cannot isolate protocols
+// unless the declared ports happen to match the ones APISIX listens on.
+func addSchemeVar(route *adctypes.Route, scheme string) {
+       route.Vars = append(route.Vars, []adctypes.StringOrSlice{
+               {StrVal: "scheme"},
+               {StrVal: "=="},
+               {StrVal: scheme},
+       })
+}
+
 // shouldInjectServerPortVars decides whether to pin the route to the matched
 // listener port(s) via a server_port predicate.
 //
diff --git a/test/e2e/gatewayapi/httproute.go b/test/e2e/gatewayapi/httproute.go
index f8b70b8f..17783e06 100644
--- a/test/e2e/gatewayapi/httproute.go
+++ b/test/e2e/gatewayapi/httproute.go
@@ -186,7 +186,10 @@ spec:
                        s.ResourceApplied("HTTPRoute", "httpbin", 
fmt.Sprintf(exactRouteByGet, gatewayName), 1)
 
                        By("access dataplane to check the HTTPRoute")
+                       // The Gateway has only an HTTPS listener, so the route 
is pinned to the
+                       // https scheme and is reached over TLS, not on the 
plaintext port.
                        s.RequestAssert(&scaffold.RequestAssert{
+                               Client:   s.NewAPISIXHttpsClient("api6.com"),
                                Method:   "GET",
                                Path:     "/get",
                                Host:     "api6.com",
@@ -195,11 +198,20 @@ spec:
                                Interval: time.Second * 2,
                        })
 
+                       By("the same request must not be served over plaintext")
+                       s.RequestAssert(&scaffold.RequestAssert{
+                               Method: "GET",
+                               Path:   "/get",
+                               Host:   "api6.com",
+                               Check:  scaffold.WithExpectedStatus(404),
+                       })
+
                        By("delete HTTPRoute")
                        err := 
s.DeleteResourceFromString(fmt.Sprintf(exactRouteByGet, gatewayName))
                        Expect(err).NotTo(HaveOccurred(), "deleting HTTPRoute")
 
                        s.RequestAssert(&scaffold.RequestAssert{
+                               Client: s.NewAPISIXHttpsClient("api6.com"),
                                Method: "GET",
                                Path:   "/get",
                                Host:   "api6.com",
@@ -2715,7 +2727,10 @@ spec:
                })
                It("HTTPS backend", func() {
                        s.ResourceApplied("HTTPRoute", "nginx", 
fmt.Sprintf(httproute, s.Namespace()), 1)
+                       // beforeEachHTTPS builds a Gateway with only an HTTPS 
listener, so the
+                       // route is reached over TLS rather than on the 
plaintext port.
                        s.RequestAssert(&scaffold.RequestAssert{
+                               Client: s.NewAPISIXHttpsClient("api6.com"),
                                Method: "GET",
                                Path:   "/get",
                                Host:   "api6.com",
diff --git a/test/e2e/scaffold/adc.go b/test/e2e/scaffold/adc.go
index 0026e2e1..30778287 100644
--- a/test/e2e/scaffold/adc.go
+++ b/test/e2e/scaffold/adc.go
@@ -28,7 +28,7 @@ import (
 
        "github.com/api7/gopkg/pkg/log"
        "go.uber.org/zap"
-       "gopkg.in/yaml.v3"
+       "sigs.k8s.io/yaml"
 
        adctypes "github.com/apache/apisix-ingress-controller/api/adc"
        "github.com/apache/apisix-ingress-controller/internal/adc/translator"
@@ -196,6 +196,9 @@ func (a *adcDataplaneResource) dumpResources(ctx 
context.Context) (*translator.T
                return nil, err
        }
 
+       // sigs.k8s.io/yaml converts to JSON first, so types with a custom
+       // UnmarshalJSON decode correctly. gopkg.in/yaml does not call it, and 
fails on
+       // adc.StringOrSlice, which every route var is built from.
        var resources adctypes.Resources
        if err := yaml.Unmarshal(yamlData, &resources); err != nil {
                return nil, err

Reply via email to