diff --git a/.github/workflows/apisix-conformance-test.yml b/.github/workflows/apisix-conformance-test.yml index dbff2fba..cd9bd03d 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 diff --git a/api/adc/types.go b/api/adc/types.go index f7adae1c..df58231f 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 fc11631f..6e413a2f 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 27f00358..ca763ab3 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 62d0ca03..2bb9fc73 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 7de4a67e..e08e09ee 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 8d1fd0a6..659a9166 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 00000000..a3fd393d --- /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/listener_utils_test.go b/internal/controller/listener_utils_test.go index 9fa4cc9a..3862b11e 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 5bbd5a59..955f7e54 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 @@ -340,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{} @@ -368,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 3b7e31fd..b0905819 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 diff --git a/test/conformance/api7ee/conformance_test.go b/test/conformance/api7ee/conformance_test.go index a9572a9f..f9aade68 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", diff --git a/test/conformance/conformance_test.go b/test/conformance/conformance_test.go index cf606fc6..223a0a73 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{ @@ -69,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) { @@ -77,7 +78,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 e7ab91b1..64783c31 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 30289194..d36074f5 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 08d4e4a3..5aa5352f 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,92 @@ 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() { + 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. + // 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 aa6d8fad..9170e587 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 03091736..7cce59de 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 d9ddb2f6..ad412768 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 41ac85c7..6961481c 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 }