Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
fccc9ab
feat: serve TLSRoute in Passthrough mode
AlinsRan Sep 20, 2026
c586e86
fix: intersect TLSRoute hostnames with the listener hostname
AlinsRan Sep 20, 2026
2a425af
test: skip TLSRouteHostnameIntersection as a known architectural gap
AlinsRan Sep 20, 2026
9b5b991
test: declare the TLSRoute gap on the API7 gateway
AlinsRan Sep 20, 2026
f224f78
ci: run the APISIX conformance job against adc:dev
AlinsRan Sep 20, 2026
6f9f3ca
test: skip the Passthrough e2e spec in API7EE mode
AlinsRan Sep 20, 2026
47ddb16
fix: verify the TLS passthrough tunnel with a handshake, and rebuild it
AlinsRan Oct 10, 2026
f732b25
Merge branch 'master' into feat/tlsroute-passthrough
AlinsRan Oct 10, 2026
f5325f9
fix: address review findings on the hostname intersection and the e2e…
AlinsRan Oct 10, 2026
e1147b1
chore: require an ADC release carrying snis and tls_passthrough
AlinsRan Oct 10, 2026
303d630
test: make the rejected-parent test self-contained
AlinsRan Oct 10, 2026
c9a30d8
Revert "test: declare the TLSRoute gap on the API7 gateway"
AlinsRan Oct 10, 2026
b473b47
fix: configure the passthrough listen on the API7 data plane too
AlinsRan Oct 10, 2026
5f628b8
fix: stop de-duplicating aggregated listeners
AlinsRan Oct 10, 2026
3364c83
fix: close the previous passthrough tunnel before replacing it
AlinsRan Oct 10, 2026
19bab29
fix: point the api7ee conformance run at the passthrough listen
AlinsRan Oct 10, 2026
0d44e2c
fix: derive TLSRoute SNIs from the listeners of each port
AlinsRan Oct 12, 2026
34399bc
docs: narrow the cross-Gateway tls.mode guarantee
AlinsRan Oct 12, 2026
040ed8e
Merge remote-tracking branch 'origin/master' into feat/tlsroute-passt…
AlinsRan Oct 12, 2026
5c7cb20
test: rebuild the TLS passthrough forward on a failed dial
AlinsRan Oct 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/apisix-conformance-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ concurrency:
permissions:
pull-requests: write

env:
ADC_VERSION: dev

jobs:
conformance-test:
env:
Expand Down Expand Up @@ -70,7 +73,6 @@ jobs:
ARCH: amd64
ENABLE_PROXY: "false"
BASE_IMAGE_TAG: "debug"
# ADC_VERSION: "dev"
run: |
echo "building images..."
make build-image
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ KIND_NAME ?= apisix-ingress-cluster
KIND_NODE_IMAGE ?= kindest/node:v1.32.2@sha256:f226345927d7e348497136874b6d207e0b32cc52154ad8323129352923a3142f

DASHBOARD_VERSION ?= dev
ADC_VERSION ?= 0.29.0
ADC_VERSION ?= 0.31.0

DIR := $(shell pwd)

Expand Down
13 changes: 12 additions & 1 deletion api/adc/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,18 @@ type StreamRoute struct {
RemoteAddr string `json:"remote_addr,omitempty"`
ServerAddr string `json:"server_addr,omitempty"`
ServerPort int32 `json:"server_port,omitempty"`
SNI string `json:"sni,omitempty"`
// SNI and SNIs are the singular and plural forms of the same match; APISIX
// rejects a stream route carrying both, so only one is ever set.
SNI string `json:"sni,omitempty"`
SNIs []string `json:"snis,omitempty"`
// TLSPassthrough forwards the TLS stream to the upstream untouched instead
// of terminating it on the gateway.
//
// A listen carrying tls_passthrough alone passes every connection through
// and never reads this; a listen carrying tls alone always terminates. It
// decides only on a listen configured with both, where the matched stream
// route picks between the terminating and the prereading server.
TLSPassthrough *bool `json:"tls_passthrough,omitempty"`
}

// +k8s:deepcopy-gen=true
Expand Down
10 changes: 10 additions & 0 deletions api/adc/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/en/latest/concepts/gateway-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. Within one Gateway, listeners that disagree on `tls.mode` for one port are reported `Accepted=False` with `ProtocolConflict` and carry no routes, and a route attached to two Gateways that disagree about the same port falls back to `Terminate`, because a physical stream listen has a single mode. Two Gateways that disagree are each valid on their own, so when different routes attach to each of them no conflict is reported and every route is translated for the mode its own listener declares — only one of which the listen can honour. The controller does not configure the stream listen, so it cannot detect or resolve that: declare one `tls.mode` per port across Gateways that share a data plane. |
| `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`. |
15 changes: 11 additions & 4 deletions internal/adc/translator/l4route_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,13 +291,20 @@ func TestTranslateTLSRouteWithL4RoutePolicy(t *testing.T) {
require.NoError(t, err)
require.Len(t, result.Services, 1)

// Verify stream routes are created per SNI hostname
if len(tt.hostnames) > 0 {
assert.Len(t, result.Services[0].StreamRoutes, len(tt.hostnames))
// One stream route carries every hostname: the singular sni for a
// single one, the plural snis beyond that. APISIX rejects both at once.
require.Len(t, result.Services[0].StreamRoutes, 1)
switch len(tt.hostnames) {
case 1:
assert.Equal(t, tt.hostnames[0], result.Services[0].StreamRoutes[0].SNI)
assert.Empty(t, result.Services[0].StreamRoutes[0].SNIs)
default:
assert.Equal(t, tt.hostnames, result.Services[0].StreamRoutes[0].SNIs)
assert.Empty(t, result.Services[0].StreamRoutes[0].SNI)
}

// Plugins are attached at the stream_route level so the APISIX stream proxy
// applies them; with multiple SNIs each stream_route carries its own copy.
// applies them.
require.NotEmpty(t, result.Services[0].StreamRoutes)
plugins := result.Services[0].StreamRoutes[0].Plugins
if tt.wantNoPlugins {
Expand Down
127 changes: 116 additions & 11 deletions internal/adc/translator/tlsroute.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ package translator
import (
"fmt"

"k8s.io/utils/ptr"
gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"

adctypes "github.com/apache/apisix-ingress-controller/api/adc"
apiv2 "github.com/apache/apisix-ingress-controller/api/v2"
"github.com/apache/apisix-ingress-controller/internal/controller/label"
cutils "github.com/apache/apisix-ingress-controller/internal/controller/utils"
"github.com/apache/apisix-ingress-controller/internal/id"
"github.com/apache/apisix-ingress-controller/internal/provider"
"github.com/apache/apisix-ingress-controller/internal/types"
Expand All @@ -34,10 +36,6 @@ func (t *Translator) TranslateTLSRoute(tctx *provider.TranslateContext, tlsRoute
result := &TranslateResult{}
rules := tlsRoute.Spec.Rules
labels := label.GenLabel(tlsRoute)
hosts := make([]string, 0, len(tlsRoute.Spec.Hostnames))
for _, hostname := range tlsRoute.Spec.Hostnames {
hosts = append(hosts, string(hostname))
}
for ruleIndex, rule := range rules {
service := adctypes.NewDefaultService()
service.Labels = labels
Expand Down Expand Up @@ -143,18 +141,125 @@ func (t *Translator) TranslateTLSRoute(tctx *provider.TranslateContext, tlsRoute
}
}

for _, host := range hosts {
streamRoutes, err := t.buildL4StreamRoutes(tctx, tlsRoute.Namespace, tlsRoute.Name, ruleIndex, "TLS", "TLSRoute", labels)
if err != nil {
return nil, err
// One set of stream routes per rule, each carrying the SNIs of its own
// port, rather than one set per hostname: buildL4StreamRoutes names them by
// rule and port only, so a per hostname loop produced N sets sharing one ID
// and only the last survived.
streamRoutes, err := t.buildL4StreamRoutes(tctx, tlsRoute.Namespace, tlsRoute.Name, ruleIndex, "TLS", "TLSRoute", labels)
if err != nil {
return nil, err
}
for _, streamRoute := range streamRoutes {
snis := tlsRouteSNIsForPort(tctx.Listeners, tlsRoute.Spec.Hostnames, streamRoute.ServerPort)
if len(snis) == 0 {
// Nothing for the stream route to match on. The listeners a route
// attaches to already intersect its hostnames, so this is a guard
// against emitting a route that would answer every SNI instead.
continue
}
for _, streamRoute := range streamRoutes {
streamRoute.SNI = host
// A single SNI keeps using the singular form: it is what every
// APISIX version understands, and snis only earns its place once
// there is more than one to match.
if len(snis) == 1 {
streamRoute.SNI = snis[0]
} else {
streamRoute.SNIs = snis
Comment thread
AlinsRan marked this conversation as resolved.
}
if tlsPassthroughOnPort(tctx.Listeners, streamRoute.ServerPort) {
streamRoute.TLSPassthrough = ptr.To(true)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
service.StreamRoutes = append(service.StreamRoutes, streamRoutes...)
service.StreamRoutes = append(service.StreamRoutes, streamRoute)
}

result.Services = append(result.Services, service)
}
return result, nil
}

// tlsRouteSNIsForPort returns the SNIs the stream route bound to port matches on.
//
// The set is derived from the listeners on that port alone. Handing every port
// the route-wide union instead would let a port answer a hostname its own
// listener never accepted, and would cost a port its catch-all as soon as a
// listener on another port named a hostname. port 0 means the stream route
// carries no server_port match, so every matched listener applies.
//
// A TLSRoute without hostnames matches everything its listeners accept, so it
// falls back to the hostnames of the listeners on the port and, when one of them
// carries no hostname either, to the catch-all "*". Emitting nothing - which is
// what the per hostname loop used to do - left such a route attached but
// unserved.
func tlsRouteSNIsForPort(listeners []gatewayv1.Listener, routeHostnames []gatewayv1.Hostname, port int32) []string {
matched := make([]gatewayv1.Listener, 0, len(listeners))
for _, listener := range listeners {
if listener.Protocol != gatewayv1.TLSProtocolType {
continue
}
if port != 0 && listener.Port != port {
continue
}
matched = append(matched, listener)
}

// No listener to narrow against: keep what the route itself asks for.
if len(matched) == 0 {
if len(routeHostnames) == 0 {
return []string{"*"}
}
return hostnamesToSNIs(routeHostnames)
}

if len(routeHostnames) == 0 {
hostnames, matchAnyHost := cutils.UnionHostnames(matched)
if matchAnyHost {
return []string{"*"}
}
return hostnamesToSNIs(cutils.DedupHostnames(hostnames))
}

snis := make([]gatewayv1.Hostname, 0, len(routeHostnames))
for _, hostname := range routeHostnames {
snis = append(snis, cutils.NarrowHostname(matched, hostname)...)
}
return hostnamesToSNIs(cutils.DedupHostnames(snis))
}

func hostnamesToSNIs(hostnames []gatewayv1.Hostname) []string {
snis := make([]string, 0, len(hostnames))
for _, hostname := range hostnames {
snis = append(snis, string(hostname))
}
return snis
}

// tlsPassthroughOnPort reports whether the stream routes bound to port must
// forward the connection untouched instead of having the gateway terminate it.
// port 0 means the StreamRoute carries no server_port match, so every matched
// listener applies.
//
// Every TLS listener this route matched on the port has to agree. Within one
// Gateway a port carrying both modes is already reported ProtocolConflict and
// attaches no routes; a route attached to two Gateways that disagree falls back
// to terminating rather than to a guess, since the physical stream listen has a
// single mode.
//
// Two *different* routes attached to two disagreeing Gateways are each
// translated for the mode their own listener declares, and only one of those is
// the mode the listen was configured with. The controller does not configure the
// stream listen, so it can neither detect that nor resolve it.
func tlsPassthroughOnPort(listeners []gatewayv1.Listener, port int32) bool {
matched := false
for _, listener := range listeners {
if listener.Protocol != gatewayv1.TLSProtocolType {
continue
}
if port != 0 && listener.Port != port {
continue
}
if listener.TLS == nil || listener.TLS.Mode == nil || *listener.TLS.Mode != gatewayv1.TLSModePassthrough {
return false
Comment thread
AlinsRan marked this conversation as resolved.
}
matched = true
}
return matched
}
Loading
Loading