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