feat(stream): support TLS passthrough on the stream proxy - #13912
Conversation
"Route by SNI" and "do not decrypt" used to be mutually exclusive in the stream subsystem: the only SNI source was a handshake the worker performed itself, so any SNI-matched stream route implied terminating TLS at the gateway. A backend that owns its own certificate, or an operator who does not want private keys on the gateway, had no way to get both. A `stream_proxy.tcp` listen can now carry `tls_passthrough: true`. Such a listen is rendered without `ssl`, with `ssl_preread on`, and `apisix/ssl.lua` reads the SNI captured by ngx_stream_ssl_preread_module instead. The encrypted stream is forwarded to the upstream untouched. Setting both `tls` and `tls_passthrough` opens a mixed port, where each connection follows the new `tls_passthrough` boolean on the stream route it matches. That flag lives in etcd, so moving a service between the two modes needs no gateway configuration change or restart. The port itself cannot be both: `ssl_preread on` and `listen ... ssl` are configuration-time directives, and on one server the handshake consumes the ClientHello before the preread phase can read it. So the two behaviours move into internal servers reachable over unix sockets, and the preread phase of the public listen picks one by setting the `proxy_pass` target. The client address crosses that hop in a PROXY protocol header restored with `set_real_ip_from unix:`, and the internal servers take `$server_addr` and `$server_port` from `$proxy_protocol_server_addr`/`_port`, so route matching, stream plugins and logging all see the real peer and the real listen address. Plugins and the log phase run once, on whichever internal server owns the connection. Only mixed ports pay that hop; `tls: true` and `tls_passthrough: true` ports keep their direct path. Consequences of not terminating, all of them inherent: - payload-inspecting stream plugins (mqtt-proxy, xrpc, redis) and gateway mTLS do not apply; mTLS moves to the backend; - an upstream with `scheme: tls` is refused with a 503 rather than silently double-wrapping, since a second handshake would send the client's ClientHello to the upstream as payload; - `apisix_stream_metrics_zone` counts nginx sessions and has no per-server switch, so a mixed port counts each client connection twice. `apisix/cli/ops.lua` now keys stream server groups on the server-level directives a listen needs, so the TLS mode joins `proxy_protocol_to_upstream` as a grouping dimension, and two listens on one address that would need different server blocks are rejected at configuration time -- nginx refuses to start over that with reuseport on, and silently keeps only the first server without it. Two test-framework fixes in t/APISIX.pm are needed by the new .t: a bare `--- stream_tls_verify` section carries an empty, false value, so the springboard called sslhandshake with verification disabled; and a block that sets `stream_server_config` alongside `stream_tls_request` rendered a second, conflicting stream block. Ported from api7/api7-ee-3-gateway#2173.
ci/linux_apisix_current_luarocks_runner.sh invokes each t/cli/test_*.sh directly, so a file without the executable bit fails the job with "Permission denied" before any assertion runs.
The test ends by blacklisting 127.0.0.1 on a route matching sni test.com on port 9100, to prove the real client address survives the internal hop. That route was left in etcd, and t/cli/test_tls_over_tcp.sh runs next on the same port with the same SNI, so it was answered with a 403 from ip-restriction and failed with "should proxy tls over tcp". Delete the stream routes and the ssl object before stopping, so the etcd instance is handed over clean.
|
Reviewed commit: [P1] Fix the invalid upstream PROXY protocol header in mixed modeWith both
An isolated stream/realip reproducer built from the NGINX bundled in OpenResty 1.29.2.4, using the same Unix socket forwarding pattern, captured a header of this form: The destination is not a valid PROXY v1 TCP4/TCP6 address. A strict PROXY protocol backend rejects it before it can process the application data or TLS handshake. This affects both the terminating and passthrough branches of a mixed listener when upstream PROXY protocol is enabled. Dedicated passthrough/terminating listeners do not take this Unix socket path. Please fix the upstream header so that it carries valid original source/destination addresses and ports, and add regression tests for both mixed-mode branches against a backend that actually parses PROXY protocol. The existing rendering check and real-client-IP test do not exercise this combination. The reproduction validates the NGINX transport behavior; it was not a full APISIX/EE end-to-end run. Code references: mixed listener template, Lua-only destination restoration. [P2] Additional socket forwarding in mixed mode: author assessment requestedPlease assess this point separately from the P1 correctness fix above. For a fixed-IP upstream, without retries or additional plugin calls, the mixed path has two outbound connections per stream session: the outer
This is a local forwarding hop, not an extra remote-service lookup. It adds socket, buffering/copying, and scheduling work for both mixed-mode branches; dedicated passthrough/terminating listeners retain their direct path. The topology is verified from the implementation, but no latency or throughput regression has been measured here. Could you confirm whether sharing one listener requires retaining this hop, evaluate connection-rate/throughput and resource usage against the direct modes, and state whether to keep this trade-off or use separate listener modes / an implementation without the extra hop? This item needs your explicit assessment; it is separate from the required PROXY protocol fix. |
A mixed listen reaches its internal servers over a unix socket, and nginx
builds the upstream PROXY protocol header from the socket the connection
arrived on. On the internal server that is the unix socket, so the header
came out as
PROXY TCP4 127.0.0.1 unix:/tmp/stls-p1.sock 56058 0
whose destination is neither a TCP4 nor a TCP6 address. A backend that
parses the PROXY protocol rejects it before reading any application data.
Restoring $server_addr/$server_port in Lua does not help: nginx reads
c->local_sockaddr, not the variable cache.
Reject the combination at configuration time instead of emitting a header
no strict parser accepts. Only mixed listens are affected -- a dedicated
`tls` or `tls_passthrough` listen has no internal hop, so nginx builds the
header from the real listen address and it is correct.
Reported by @membphis on apache#13912.
Thanks — P1 reproduced exactly as you describe, and it is fixed in 2a2d39f.
the hop is inherent to sharing one listener, not an implementation choice. So the |
|
Non-blocking observation for multi-worker deployments: mixed TLS mode makes the routing decision twice. The public listener first selects the terminating or passthrough internal server, and the internal Unix-socket server then matches the route again to select the upstream. During a control-plane or route/service configuration update, different workers may temporarily observe different snapshots, so a connection could combine the TLS mode from one snapshot with the upstream from another. This is more exposed with a large worker count. No action is required for this approval, but please keep the consistency window in mind and consider a multi-worker update regression or carrying the routing/version decision across the internal hop. |
Description
Adds TLS passthrough to the stream proxy: a
stream_proxy.tcplisten can now forward the encrypted stream to the upstream untouched while still picking that upstream from the SNI.Until now "route by SNI" and "don't decrypt" were mutually exclusive in the stream subsystem — the only SNI source was a handshake the worker performed itself, so any SNI-matched stream route implied terminating TLS at the gateway. A backend that owns its own certificate, or an operator who does not want private keys on the gateway, had no way to get both.
Motivation: Gateway API TLSRoute passthrough
Gateway API defines TLSRoute as
and pairs it with a listener in
Passthroughmode, described in the TLS configuration guide:That combination is what the stream proxy could not express: picking a backend from the SNI required terminating the handshake first. This change makes both hold at once — the SNI is taken from the prereaded ClientHello, and the session reaches the backend untouched.
Three modes per listen
A passthrough listen is rendered without
ssl, withssl_preread on, andapisix/ssl.luareads the SNI captured byngx_stream_ssl_preread_moduleinstead ofngx.ssl.server_name().On a mixed port each connection is terminated or passed through according to
tls_passthroughon thestream_routeit matches (new field, defaultfalse). That comes from etcd, so moving a service between the two modes needs no gateway config change or restart.Why the port mode itself is static
ssl_preread onandlisten ... sslare config-time directives with no runtime switch, and on one server they are incompatible — measured: the handshake completes, the preread phase then never sees a ClientHello and the connection stalls. So a port is structurally either terminate or preread.What can be dynamic is the choice per connection. The two behaviours move into internal servers reachable over unix sockets; the preread phase of the public listen matches the route and sets the
proxy_passtarget:Plugins and the log phase run once, on whichever internal server owns the connection; the outer block matches the route and does nothing else. The client address crosses the hop in a PROXY protocol header and is restored with
set_real_ip_from unix:;$server_addr/$server_portare restored from$proxy_protocol_server_addr/_port, so astream_routecarryingserver_addr/server_portmatches the same on both sides of the hop.Only mixed ports pay that hop.
tls: trueandtls_passthrough: trueports keep their direct path.Consequences of not terminating
These are inherent to passthrough, not implementation limits:
mqtt-proxy,xrpc,redis) and gateway mTLS do not apply; mTLS moves to the backend;"scheme": "tls"is refused with a503and a diagnostic rather than silently double-wrapping — a second handshake would send the client's ClientHello to the upstream as payload;apisix_stream_metrics_zonecounts nginx sessions and has no per-server switch, so a mixed port counts each client connection twice.It is an explicit opt-in per listen port, never a default.
Changes
apisix/cli/schema.luatls_passthroughapisix/cli/ops.luaproxy_protocol_to_upstream; rejects two listens on one address that would need different server blocks; names and length-checks the internal socketsapisix/cli/ngx_tpl.luassl_preread on, and for mixed listens the outer preread block plus the two internal servers and their upstreamsapisix/ssl.luaserver_name(clienthello, preread)— reads$ssl_preread_server_namewhen nothing was terminated locally, normalising""tonilsofallback_snistill applies to a no-SNI ClientHelloapisix/init.luastream_preread_phase(tls_passthrough, behind_mixed_hop)skipsverify_tls_clientand restores the listen address; newstream_tls_route_phase()for the outer blockapisix/schema_def.luastream_route.tls_passthroughapisix/stream/router/ip_port.luaapisix/upstream.luascheme: tlsunder passthrough returns 503, ahead of the traffic-split short circuit so an inline upstream from that plugin is covered toot/APISIX.pm.t, see belowGrouping listens by their TLS mode extends the existing
proxy_protocol_to_upstreamsplit rather than replacing it;t/cli/test_stream_proxy_protocol.sh(11 assertions on that grouping) stays green.Test framework
Both fixes are required by the new
.tand both were latent bugs:--- stream_tls_verifysection carries an empty value, which Perl treats as false, so the springboard calledsslhandshakewith verification disabled. Withdefined, a stream TLS test can actually verify the served chain. Checked both directions: pointingcustom_trusted_certat a certificate that does not sign the backend's now fails the case, pointing it at the backend's CA passes.stream_server_configalongsidestream_tls_requestrendered a second, conflictingstream {}block, because on that path the stream block lives in the main config.Tests
t/stream-node/tls-passthrough.t— 9 cases, 27 assertions:t/cli/test_stream_tls_passthrough.sh— 8 assertionsChecklist