You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
APISIX's stream proxy can now forward a TLS stream to the upstream untouched while still picking that upstream from the SNI, which it prereads from the ClientHello rather than learning from a handshake it performed itself (apache/apisix#13912). A stream listen opts in per port:
That is exactly what Gateway API asks of a listener in Passthrough mode, and it is the one thing the stream subsystem could not do before: routing by SNI implied terminating.
So a Passthrough listener behaved as Terminate. The translator never looked at tls.mode, the gateway decrypted a stream the backend was supposed to own, and the handshake failed against a certificate the gateway does not have.
TranslateTLSRoute reads the tls.mode of the listeners the route attached to and sets tls_passthrough on the stream routes bound to a Passthrough port. The TLSRoute reconciler populates tctx.Listeners for that, which the TCPRoute and UDPRoute ones already did.
Every TLS listener the route matched on a port has to agree. A physical stream listen is either terminating or prereading, never both, so a route attached to parents that disagree falls back to terminating rather than to a guess. Within one Gateway such a port is already ProtocolConflict and attaches no routes at all. Two Gateways that disagree are each valid on their own, so two different routes attached to one each are translated for their own listener's mode and only one of those matches how the listen was configured — the controller does not configure the listen, so it cannot detect or resolve that. The Gateway API doc now says so rather than promising a fallback that does not happen.
SNIs
A TLSRoute's hostnames become the SNIs its stream routes match on, and three defects in that derivation are fixed here.
One stream route per rule, not per hostname.ComposeStreamRouteName does not vary with the hostname, so N hostnames produced N stream routes sharing one id and only the last survived. A rule now emits one stream route per port carrying every SNI — sni for a single one, snis beyond that, never both, since the schema forbids it.
The SNIs of each port come from the listeners on that port. A route-wide set let a port answer a hostname only another port's listener had accepted, and cost a port its catch-all as soon as a listener elsewhere named a hostname.
A route hostname intersects with every listener hostname it covers, not just the first. A wildcard spanning two same-port listeners used to be emitted for one of them, and the other's traffic was dropped. The same gap applied to HTTPRoute, which shares the intersection.
The route's hostnames are also intersected with the listener hostnames before translation (filterTLSRouteHostnames), the way HTTPRoute's already were; only accepted parentRefs widen that intersection, because a rejected one carries no matched listener and would otherwise fall back to every listener on its Gateway.
The TLSRoute reconciler syncs that narrowed copy, so the data plane only ever sees the intersected hostnames. A route whose hostnames intersect with none of its listeners is deleted from the data plane and reports Accepted=False with NoMatchingListenerHostname, as HTTPRoute already does.
The Gateway API hostname matchers move to internal/controller/utils/hostname.go (NarrowHostname, UnionHostnames, DedupHostnames and the existing HostnamesMatch/HostnamesIntersect), because the translator needs them per port and cannot import internal/controller.
Effect on HTTPRoute
HTTPRoute shares intersectRouteHostnames, so two of these fixes reach it too:
a route hostname now yields every listener hostname it covers rather than only the first, de-duplicated across Gateways;
only accepted parentRefs contribute listeners to the intersection.
A route attached to one listener, or whose hostnames each cover at most one listener hostname, produces the same hostnames as before.
Conformance
The GATEWAY-TLS core tests run against both providers: 19 passed, 0 failed, 1 skipped, and the extended ones 3 passed with 1 skipped. The TLSRoute tests pinned to mode: Passthrough are no longer skipped:
TLSRouteSimpleSameNamespace
TLSRouteInvalidBackendRefNonexistent
TLSRouteInvalidBackendRefUnknownKind
TLSRoute is a CORE feature of the GATEWAY-TLS profile, so nothing is added to SUPPORTED_EXTENDED_FEATURES.
Their Gateway listener is fixed at port 443, and the suite dials the Gateway address there. One port cannot serve both HTTP-over-TLS and an untouched stream, so the conformance data plane points its 443 service port at APISIX's stream tls_passthrough listen instead of its HTTP ssl listen (DeployDataplaneOptions.ServiceHTTPSTargetPort). The HTTPRoute tests that would want HTTP-over-TLS on 443 — HTTPRouteHTTPSListener, HTTPRouteRedirectPortAndScheme — are already skipped for unrelated SAN reasons. The e2e data plane keeps 443 on the HTTP ssl listen and exposes the passthrough listen on 9120 of its own.
Two tests stay skipped, both with the reason recorded in the skip list:
TLSRouteHostnameIntersection — an architectural limit rather than a translator gap, the same one HTTPRouteMultipleGateways is already skipped for. 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. Every other assertion in the test passes, the hostname intersections themselves included.
TLSRouteTerminateSimpleSameNamespace — provisional, and it needs a standalone Gateway with no GatewayProxy attached to reach Accepted=True. Terminate mode itself is covered by TLSRouteListenerTerminateSupportedKinds and by the e2e TLSRoute suite.
TLSRouteModeMixed is deliberately not claimed. Claiming it would require relaxing portsWithConflictingTLSMode and making the provisional TLSRouteMixedTerminationSameNamespace pass on a mixed listen; that is a follow-up.
Tests
internal/adc/translator/tlsroute_test.go — the mode of each port drives tls_passthrough (passthrough, terminate, omitted-mode, per-port, disagreeing listeners, portless, no listener); sni vs snis; the hostname fallbacks; per-port SNI derivation, including a port keeping its catch-all while a sibling names a hostname, and one port with several listeners keeping every intersection.
internal/controller/utils_hostname_test.go — the hostname intersection keeps every match and de-duplicates it, and ignores the listeners of a rejected parentRef.
test/e2e/gatewayapi/tlsroute.go — a Passthrough listener on 9120 in front of the e2e nginx, with the client verifying the served chain against the backend's CA. The gateway holds no certificate for a Passthrough listener (it takes no certificateRefs), so a chain that validates there can only have come from the backend — which is what passthrough means. The spec runs on every provider, with no Skip.
test/e2e/scaffold/scaffold.go — the passthrough port is forwarded on first use and every dial is verified with a real TLS handshake for the route's SNI, rebuilding the kubectl port-forward when it fails. APISIX resets a passthrough connection that no stream route matches yet, which kills the port-forward; and with worker_processes: 2 in standalone mode one worker can still reset after another has already served the route, so a one-off check at creation time is not enough.
test/e2e/gatewayapi/udproute.go — an unrelated flake fix: the spec re-applied GatewayClass/Gateway YAML fetched from the cluster, whose stale resourceVersion raced the controller's status update into a Conflict.
make lint and make test are clean; make generate manifests produces no drift.
Version pins
feat(core): support snis and tls_passthrough configurations for stream route api7/adc#618 is merged and released in ADC v0.31.0, which is what snis and tls_passthrough need: ADC's streamRouteSchema is a z.strictObject and its Rust StreamRoute is deny_unknown_fields, so both flavours reject the fields before that release. ADC_VERSION and the workflow's ADC_RUST_VERSION are bumped to 0.31.0; 0.29.0 and 0.30.5 were checked to reject the two fields and 0.31.0 to accept them.
config/manager/kustomization.yaml pins the ADC sidecar image to 0.31.0 too. ADC_VERSION only reaches the manifest through kustomize edit set image in the deploy targets, so a plain kustomize build config/default was still shipping 0.30.5.
tls_passthrough is only emitted for a Passthrough listener and snis only for more than one SNI, so a Terminate-only single-hostname setup — everything that works today — serializes byte-identically.
The stream proxy can now forward a TLS stream to the upstream untouched while
still picking that upstream from the SNI, which it prereads from the
ClientHello rather than learning from a handshake it performed itself
(apache/apisix#13912). That is exactly what Gateway API asks of a listener in
Passthrough mode, and until now the only thing the stream subsystem could not
do - routing by SNI implied terminating.
A Passthrough listener therefore behaved as Terminate: the translator never
looked at tls.mode, so the gateway decrypted a stream the backend was supposed
to own, and the handshake failed against a certificate the gateway does not
have.
- TLSRoute now reads the tls.mode of the listeners it attached to and sets
tls_passthrough on the stream routes bound to a Passthrough port. Its
controller populates tctx.Listeners for that, as the TCPRoute and UDPRoute
ones already did.
- Every matched listener on a port has to agree on the mode. A physical stream
listen is either terminating or prereading, never both, so listeners that
disagree fall back to terminating rather than to a guess. Within one Gateway
such a port is already ProtocolConflict and attaches no routes at all.
Two adjacent defects in the same code, both of which passthrough would have
made visible:
- every hostname produced its own StreamRoute under one name, so they all
collapsed onto a single id and only the last survived. One StreamRoute now
carries them all - sni for a single hostname, snis beyond that, never both,
which APISIX rejects.
- the StreamRoutes carried no server_port, so several listener ports fell onto
one route and shared an id (the TCPRoute/UDPRoute side of this was apache#2802).
TLSRoute now goes through the same per-port fan-out, gated by the same
listener_port_match_mode.
A TLSRoute with no hostnames used to produce no StreamRoute at all - attached,
but unserved. It now falls back to the listener hostnames and, failing those,
to the catch-all "*".
Conformance: the four TLSRoute tests pinned to Passthrough are no longer
skipped. Their Gateway listener is fixed at port 443, so the conformance data
plane points its 443 service port at the stream tls_passthrough listen instead
of the HTTP ssl listen - one port cannot serve both, and the HTTPRoute tests
that would want HTTP-over-TLS there are skipped for unrelated SAN reasons.
E2E adds a Passthrough spec that verifies the served chain against the
backend's own CA: the gateway holds no certificate for a Passthrough listener,
so a chain that validates there can only have come from the backend.
Requires ADC to carry the new fields (api7/adc#618).
A TLSRoute's hostnames become the SNIs its stream routes match on, and they
were used verbatim. Gateway API defines the effective hostnames as the
intersection with the listener hostname, so a route attached to a narrower
listener served names that listener never accepted.
On one shared stream listen that is not merely over-serving. Every Gateway
resolves to the same data plane, so an over-broad SNI takes that name from the
route whose listener does accept it: a route with "*.example.com" attached to
an "abc.example.com" listener answered every *.example.com connection,
including the ones a sibling Gateway's route was there to serve.
HTTPRoute has narrowed its hostnames this way since it was written -
filterHostnames plus getMinimumHostnameIntersection. Both now share
intersectRouteHostnames, and the TLSRoute reconciler applies it exactly as the
HTTPRoute one does, translating the narrowed copy and reporting
NoMatchingListenerHostname when nothing intersects.
The Gateway API conformance test TLSRouteHostnameIntersection is what surfaced
this; with the fix its intersections pass.
One assertion in it cannot hold here, and not for want of translating
correctly. The test stands four Gateways up on port 443 with different listener
hostnames; every Gateway resolves to the one data plane address and the one
physical stream listen, so their SNI namespaces are shared.
The Gateway whose listener carries no hostname keeps its route's "*.com"
verbatim - correctly, and its own subtest depends on it - which then also
answers "non.matching.com" on the address of the Gateway that should have
rejected that connection. Which Gateway a connection was addressed to is not on
the wire, so nothing is left to discriminate on.
This is the same limitation HTTPRouteMultipleGateways is already skipped for,
and it sits beside it. Every other assertion in the test passes, including the
hostname intersections themselves.
master extracted the per-listener-port StreamRoute building this series added
to tcproute.go into a shared buildL4StreamRoutes, now used by TCP, TLS and UDP.
Resolved onto that helper:
- TLSRoute builds one set of stream routes per rule and carries every SNI on
them, instead of master's per-hostname loop. buildL4StreamRoutes names routes
by rule and port only, so a loop over N hostnames still produces N sets
sharing one ID and only the last survives - the collision this series fixes.
- Dropped TestTranslateTLSRouteServerPort from tlsroute_test.go. master's
l4route_serverport_test.go covers the same ground table-driven, across more
listener modes, and asserts the same id and name uniqueness.
master's apache#2876 gave buildL4StreamRoutes an error return and inlined the port
set computation again, superseding the l4StreamRoutePorts helper this series
had extracted. Resolved onto master's signature:
- TLSRoute propagates the new error and still builds one set of stream routes
per rule carrying every SNI, rather than master's per-hostname loop.
- Dropped l4StreamRoutePorts, now that nothing calls it.
The Passthrough spec failed on both providers with a dead tunnel:
portforward.go:522 "Unhandled Error" err="an error occurred forwarding
36269 -> 9120: error forwarding port 9120 to pod ...: failed to execute
portforward in network namespace ..."
followed by three minutes of "connection refused" on the local end. The port
was fine - 118 of the 119 forwards to 9120 in that run succeeded - and so was
the data plane. The forward was simply set up too early: it went in alongside
the HTTP tunnels when the data plane was deployed, and APISIX opens its stream
listens later than its HTTP ones, so the forward was established against a port
nothing accepted on yet, died, and nothing re-established it. The error
surfaced only here because this is the one spec that dials that port; the other
~118 forwards were never used, so breaking one of those was silent.
Forwarded on first use instead, and retried. ForwardPortE returns before the
forward has proxied anything, so a listen the data plane has not opened yet
still looks successful - the retry dials the endpoint to decide. On demand also
means the rest of the suite stops paying for a port-forward it never uses.
Ran the spec four times against a kind cluster: 4/4 green.
The previous attempt checked the forward with a bare TCP dial, which proves
nothing: kubectl port-forward accepts on the local side first and only then
tries to proxy, so the dial succeeds whatever the far end does.
What CI shows is the forward dying mid-flight. Inside the pod's netns:
an error occurred forwarding 38715 -> 9120: ...
read tcp4 127.0.0.1:45296->127.0.0.1:9120: read: connection reset by peer
and from then on the local end is simply gone:
Get "https://localhost:38715/": dial tcp [::1]:38715: connect: connection refused
A reset rather than ECONNREFUSED means something is listening and hangs up, and
the stream route that was synced is correct - sni server.example.com,
server_port 9120, tls_passthrough true. What makes the far end reset is not
established; this does not try to guess it.
Instead the tunnel is now proven the only way that matches how the spec uses
it: a real handshake for the SNI. An attempt that cannot complete one closes
the forward and builds another, so a tunnel is only handed out once traffic has
actually crossed it. Forwarding on demand is kept - the suite was opening ~118
of these and using one.
This reproduces only in CI: five local runs across both versions of the check
never saw the reset, so a green local run does not distinguish them.
AlinsRan
added a commit
to api7/api7-ingress-controller
that referenced
this pull request
Oct 10, 2026
Backport of the same fix in apache/apisix-ingress-controller#2882, where this
spec failed on every attempt across both providers until it landed.
The tunnel was forwarded alongside the HTTP ones when the data plane was
deployed, and handed to the spec as-is. Two problems with that. The forward can
die mid-flight - inside the pod's netns:
an error occurred forwarding <local> -> 9120: ...
read tcp4 127.0.0.1:...->127.0.0.1:9120: read: connection reset by peer
after which the local end is simply gone, and every request reports
dial tcp [::1]:<local>: connect: connection refused
which points at the wrong layer: a reset rather than ECONNREFUSED means the
data plane listened and hung up, and the stream route synced for it is correct.
What makes the far end reset is not established, and this does not guess at it.
And checking the forward with a bare TCP dial proves nothing, because kubectl
port-forward accepts locally first and only then tries to proxy.
So the tunnel is now proven the way the spec uses it - a real handshake for the
SNI - and an attempt that cannot complete one closes the forward and builds
another. It is also forwarded on demand: createDataplaneTunnels runs for every
spec in the suite, so one consumer was costing ~118 forwards.
tlsPassthroughTunnel is byte-identical to the upstream version; the HTTP/2
tunnel this fork carries is kept alongside.
… scaffold
Three findings raised on the backport of this series in the enterprise fork,
all confirmed here as well — the code is the same on both sides.
**A rejected parentRef widened the hostname intersection.**
ParseRouteParentRefs returns a context per parentRef whether or not it was
accepted, and a rejected one carries no matched listener - `Listener` nil,
`Listeners` empty, `ListenerName` empty - so listenersForGatewayContext falls
through to its last branch:
```go
return gateway.Gateway.Spec.Listeners
```
every listener on that Gateway. A hostname only that Gateway accepts therefore
survived intersectRouteHostnames and, for a TLSRoute, became an SNI served
through the parentRef that *was* accepted. Only accepted contexts may widen the
set now. This is pre-existing behaviour of filterHostnames for HTTPRoute, which
this series extended to TLSRoute by sharing the helper, so the fix covers both;
the test covers the union and the minimum-intersection branch.
**The passthrough tunnel ignored the configured kubeconfig.**
It built fresh `NewKubectlOptions("", "", ns)` while every other dataplane
tunnel is forwarded with the scaffold's own options, so a run that sets a
kubeconfig or context would have targeted the ambient cluster instead.
**An additional gateway dropped a ServiceHTTPSTargetPort override.**
CreateAdditionalGatewayWithOptions copies the HTTP and HTTPS port overrides but
not the target port, so an additional gateway stayed on 9443 and could not be
pointed at the tls_passthrough listen.
The comment said APISIX "only consults it on a stream listen configured with
both tls and tls_passthrough". That is accurate - stream_tls_route_phase, the
only reader of the field, is the preread phase of a mixed listen - but a review
read it as both flags having to be set for passthrough to work at all, so it
now says what each of the three listen shapes does.
tlsPassthroughTunnel forwards a new port every call and overwrites the stored
handle. Tunnels.Close only knows about the one currently stored, so a spec that
built a second client - one per SNI, say - left the first kubectl port-forward
running until the process exited.
A TLSRoute's SNIs were computed once for the whole route and handed to
every per-port stream route. A port then answered hostnames only another
port's listener had accepted, and a port whose listener carries no
hostname lost its catch-all as soon as a listener elsewhere named one -
the fallback skipped hostname-less listeners and only reached "*" when no
listener had a hostname at all. Each stream route now derives its SNIs
from the listeners on its own port.
Narrowing a route hostname against the listeners also kept only the first
intersection, because the lookup returned a single "smallest
intersection". A route whose wildcard covers two same-port listeners was
emitted for one of them and the other's traffic was dropped. Every
intersection is now collected and de-duplicated, which fixes the same gap
for HTTPRoute.
The hostname matchers move to internal/controller/utils so the translator
can share them instead of re-implementing wildcard matching, and the
Gateway API doc records that the ProtocolConflict check for mixed
tls.mode is scoped to one Gateway.
Correct explanation of listener context fallback behavior
internal/controller/utils.go:1443
This explanation is inconsistent with listenersForGatewayContext, which returns nil when a context has no matched Listeners or Listener; it does not fall back to every listener on the Gateway. Please describe the actual reason for excluding rejected parents without documenting a nonexistent fallback.
The ProtocolConflict check is per Gateway and tlsPassthroughOnPort sees
only the listeners the route itself matched, so the fallback to Terminate
covers one route attached to disagreeing parents - not two routes
attached to one disagreeing Gateway each. Those are translated for their
own listener's mode, and only one of the two is the mode the stream
listen was configured with. The controller does not own that listen, so
it can neither detect nor resolve the conflict; say so instead of
promising a fallback that does not happen.
Also bump the checked-in ADC sidecar image to 0.31.0. The Makefile
default moved with ADC_VERSION, but config/manager/kustomization.yaml is
what a plain kustomize build deploys, and 0.30.5 rejects snis and
tls_passthrough.
A single handshake check is not enough: APISIX standalone workers apply
the pushed config independently, so a later connection can still be
reset by a worker that has not loaded the stream route yet. The reset
kills kubectl port-forward and every retry then hits "connection refused".
Verify the tunnel with a handshake on each dial and rebuild it on failure.
Also stop re-applying fetched GatewayClass/Gateway YAML in the UDPRoute
spec: the stale resourceVersion causes a Conflict after a status update.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
APISIX's stream proxy can now forward a TLS stream to the upstream untouched while still picking that upstream from the SNI, which it prereads from the
ClientHellorather than learning from a handshake it performed itself (apache/apisix#13912). A stream listen opts in per port:That is exactly what Gateway API asks of a listener in
Passthroughmode, and it is the one thing the stream subsystem could not do before: routing by SNI implied terminating.So a
Passthroughlistener behaved asTerminate. The translator never looked attls.mode, the gateway decrypted a stream the backend was supposed to own, and the handshake failed against a certificate the gateway does not have.TranslateTLSRoutereads thetls.modeof the listeners the route attached to and setstls_passthroughon the stream routes bound to aPassthroughport. The TLSRoute reconciler populatestctx.Listenersfor that, which the TCPRoute and UDPRoute ones already did.ProtocolConflictand attaches no routes at all. Two Gateways that disagree are each valid on their own, so two different routes attached to one each are translated for their own listener's mode and only one of those matches how the listen was configured — the controller does not configure the listen, so it cannot detect or resolve that. The Gateway API doc now says so rather than promising a fallback that does not happen.SNIs
A TLSRoute's hostnames become the SNIs its stream routes match on, and three defects in that derivation are fixed here.
ComposeStreamRouteNamedoes not vary with the hostname, so N hostnames produced N stream routes sharing one id and only the last survived. A rule now emits one stream route per port carrying every SNI —snifor a single one,snisbeyond that, never both, since the schema forbids it.The route's hostnames are also intersected with the listener hostnames before translation (
filterTLSRouteHostnames), the way HTTPRoute's already were; only accepted parentRefs widen that intersection, because a rejected one carries no matched listener and would otherwise fall back to every listener on its Gateway.The TLSRoute reconciler syncs that narrowed copy, so the data plane only ever sees the intersected hostnames. A route whose hostnames intersect with none of its listeners is deleted from the data plane and reports
Accepted=FalsewithNoMatchingListenerHostname, as HTTPRoute already does.The Gateway API hostname matchers move to
internal/controller/utils/hostname.go(NarrowHostname,UnionHostnames,DedupHostnamesand the existingHostnamesMatch/HostnamesIntersect), because the translator needs them per port and cannot importinternal/controller.Effect on HTTPRoute
HTTPRoute shares
intersectRouteHostnames, so two of these fixes reach it too:A route attached to one listener, or whose hostnames each cover at most one listener hostname, produces the same hostnames as before.
Conformance
The GATEWAY-TLS core tests run against both providers: 19 passed, 0 failed, 1 skipped, and the extended ones 3 passed with 1 skipped. The TLSRoute tests pinned to
mode: Passthroughare no longer skipped:TLSRouteSimpleSameNamespaceTLSRouteInvalidBackendRefNonexistentTLSRouteInvalidBackendRefUnknownKindTLSRouteis a CORE feature of the GATEWAY-TLS profile, so nothing is added toSUPPORTED_EXTENDED_FEATURES.Their Gateway listener is fixed at port 443, and the suite dials the Gateway address there. One port cannot serve both HTTP-over-TLS and an untouched stream, so the conformance data plane points its 443 service port at APISIX's stream
tls_passthroughlisten instead of its HTTP ssl listen (DeployDataplaneOptions.ServiceHTTPSTargetPort). The HTTPRoute tests that would want HTTP-over-TLS on 443 —HTTPRouteHTTPSListener,HTTPRouteRedirectPortAndScheme— are already skipped for unrelated SAN reasons. The e2e data plane keeps 443 on the HTTP ssl listen and exposes the passthrough listen on 9120 of its own.Two tests stay skipped, both with the reason recorded in the skip list:
TLSRouteHostnameIntersection— an architectural limit rather than a translator gap, the same oneHTTPRouteMultipleGatewaysis already skipped for. 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*.comverbatim — correctly, and its own subtest depends on it — which then also answersnon.matching.comon the address of the Gateway that should have rejected it. Which Gateway a connection was addressed to is not on the wire. Every other assertion in the test passes, the hostname intersections themselves included.TLSRouteTerminateSimpleSameNamespace— provisional, and it needs a standalone Gateway with no GatewayProxy attached to reachAccepted=True.Terminatemode itself is covered byTLSRouteListenerTerminateSupportedKindsand by the e2e TLSRoute suite.TLSRouteModeMixedis deliberately not claimed. Claiming it would require relaxingportsWithConflictingTLSModeand making the provisionalTLSRouteMixedTerminationSameNamespacepass on a mixed listen; that is a follow-up.Tests
internal/adc/translator/tlsroute_test.go— the mode of each port drivestls_passthrough(passthrough, terminate, omitted-mode, per-port, disagreeing listeners, portless, no listener);snivssnis; the hostname fallbacks; per-port SNI derivation, including a port keeping its catch-all while a sibling names a hostname, and one port with several listeners keeping every intersection.internal/controller/utils_hostname_test.go— the hostname intersection keeps every match and de-duplicates it, and ignores the listeners of a rejected parentRef.test/e2e/gatewayapi/tlsroute.go— aPassthroughlistener on 9120 in front of the e2e nginx, with the client verifying the served chain against the backend's CA. The gateway holds no certificate for a Passthrough listener (it takes nocertificateRefs), so a chain that validates there can only have come from the backend — which is what passthrough means. The spec runs on every provider, with noSkip.test/e2e/scaffold/scaffold.go— the passthrough port is forwarded on first use and every dial is verified with a real TLS handshake for the route's SNI, rebuilding thekubectl port-forwardwhen it fails. APISIX resets a passthrough connection that no stream route matches yet, which kills the port-forward; and withworker_processes: 2in standalone mode one worker can still reset after another has already served the route, so a one-off check at creation time is not enough.test/e2e/gatewayapi/udproute.go— an unrelated flake fix: the spec re-applied GatewayClass/Gateway YAML fetched from the cluster, whose staleresourceVersionraced the controller's status update into aConflict.make lintandmake testare clean;make generate manifestsproduces no drift.Version pins
snisandtls_passthroughneed: ADC'sstreamRouteSchemais az.strictObjectand its RustStreamRouteisdeny_unknown_fields, so both flavours reject the fields before that release.ADC_VERSIONand the workflow'sADC_RUST_VERSIONare bumped to0.31.0; 0.29.0 and 0.30.5 were checked to reject the two fields and 0.31.0 to accept them.CONFORMANCE_DATAPLANE_VERSIONmoves to3.19.0-debian, the first APISIX release carrying feat(stream): support TLS passthrough on the stream proxy apisix#13912.config/manager/kustomization.yamlpins the ADC sidecar image to0.31.0too.ADC_VERSIONonly reaches the manifest throughkustomize edit set imagein thedeploytargets, so a plainkustomize build config/defaultwas still shipping 0.30.5.tls_passthroughis only emitted for aPassthroughlistener andsnisonly for more than one SNI, so a Terminate-only single-hostname setup — everything that works today — serializes byte-identically.