AlinsRan opened a new pull request, #13912:
URL: https://github.com/apache/apisix/pull/13912
### Description
Adds TLS passthrough to the stream proxy: a `stream_proxy.tcp` listen 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.
#### Three modes per listen
```yaml
apisix:
proxy_mode: "http&stream"
stream_proxy:
tcp:
- addr: 9100
tls: true # terminate (unchanged)
- addr: 9101
tls_passthrough: true # pass everything through
- addr: 9102
tls: true # mixed: the matched stream_route decides
tls_passthrough: true
```
A passthrough listen is rendered without `ssl`, with `ssl_preread on`, and
`apisix/ssl.lua` reads the SNI captured by `ngx_stream_ssl_preread_module`
instead of `ngx.ssl.server_name()`.
On a **mixed** port each connection is terminated or passed through
according to `tls_passthrough` on the `stream_route` it matches (new field,
default `false`). 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 on` and `listen ... ssl` are 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_pass` target:
```nginx
server {
listen 9102; # no ssl
ssl_preread on;
proxy_protocol on; # carries the client across the
internal hop
access_log off;
set $stream_tls_target "";
preread_by_lua_block { apisix.stream_tls_route_phase("…_terminate_2",
"…_passthrough_2") }
proxy_pass $stream_tls_target;
}
server { # internal: terminates
listen unix:…/stls-t2.sock ssl proxy_protocol;
set_real_ip_from unix:;
ssl_certificate_by_lua_block { apisix.ssl_phase() }
preread_by_lua_block { apisix.stream_preread_phase(nil, true) }
proxy_pass apisix_backend;
}
server { # internal: passes through
listen unix:…/stls-p2.sock proxy_protocol;
set_real_ip_from unix:;
ssl_preread on; # the ClientHello was inspected, never
consumed
preread_by_lua_block { apisix.stream_preread_phase(true, true) }
proxy_pass apisix_backend;
}
```
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_port` are restored from
`$proxy_protocol_server_addr`/`_port`, so a `stream_route` carrying
`server_addr`/`server_port` matches the same on both sides of the hop.
Only mixed ports pay that hop. `tls: true` and `tls_passthrough: true` ports
keep their direct path.
#### Consequences of not terminating
These are inherent to passthrough, not implementation limits:
- 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` and a
diagnostic rather than silently double-wrapping — 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.
It is an explicit opt-in per listen port, never a default.
### Changes
| File | Change |
|---|---|
| `apisix/cli/schema.lua` | per-listen `tls_passthrough` |
| `apisix/cli/ops.lua` | groups stream listens by TLS mode (plain /
passthrough / mixed) × `proxy_protocol_to_upstream`; rejects two listens on one
address that would need different server blocks; names and length-checks the
internal sockets |
| `apisix/cli/ngx_tpl.lua` | renders `ssl_preread on`, and for mixed listens
the outer preread block plus the two internal servers and their upstreams |
| `apisix/ssl.lua` | `server_name(clienthello, preread)` — reads
`$ssl_preread_server_name` when nothing was terminated locally, normalising
`""` to `nil` so `fallback_sni` still applies to a no-SNI ClientHello |
| `apisix/init.lua` | `stream_preread_phase(tls_passthrough,
behind_mixed_hop)` skips `verify_tls_client` and restores the listen address;
new `stream_tls_route_phase()` for the outer block |
| `apisix/schema_def.lua` | `stream_route.tls_passthrough` |
| `apisix/stream/router/ip_port.lua` | passes the mode through; no routing
logic change |
| `apisix/upstream.lua` | `scheme: tls` under passthrough returns 503, ahead
of the traffic-split short circuit so an inline upstream from that plugin is
covered too |
| `t/APISIX.pm` | two test-framework fixes needed by the new `.t`, see below
|
Grouping listens by their TLS mode extends the existing
`proxy_protocol_to_upstream` split 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 `.t` and both were latent bugs:
- a bare `--- stream_tls_verify` section carries an empty value, which Perl
treats as false, so the springboard called `sslhandshake` with verification
disabled. With `defined`, a stream TLS test can actually verify the served
chain. Checked both directions: pointing `custom_trusted_cert` at a certificate
that does not sign the backend's now fails the case, pointing it at the
backend's CA passes.
- a block that sets `stream_server_config` alongside `stream_tls_request`
rendered a second, conflicting `stream {}` block, because on that path the
stream block lives in the main config.
### Tests
`t/stream-node/tls-passthrough.t` — 9 cases, 27 assertions:
1. the prereaded SNI selects an upstream, which terminates the handshake
itself. The passthrough listen holds no certificate at all, the backend
presents one the gateway has no copy of, and the client verifies that chain
against its CA — point the trust store at the gateway's own certificate instead
and the case fails, so a terminating gateway could not satisfy it;
2. a second SNI on the same listen routes independently;
3. a ClientHello with no SNI falls back to `ssl.fallback_sni` instead of
matching on `""`;
4. the mixed-mode phase picks its target from
`stream_route.tls_passthrough`. The two internal upstreams are cross-mapped
against the routes' own upstreams, so the backend that answers can only be
explained by the target the phase picked;
5. an upstream with `scheme: tls` is refused, with the diagnostic.
**13 of the 27 assertions fail on the parent commit** (with only the
`t/APISIX.pm` framework fixes applied, so the failures are attributable to the
feature and not to the harness), and all 27 pass with the change.
`t/cli/test_stream_tls_passthrough.sh` — 8 assertions, only what a `.t`
structurally cannot reach. Test::Nginx builds its own `nginx.conf` and never
runs `apisix/cli/ops.lua` or `ngx_tpl.lua`, so the half of this feature that
*is* config generation has no other home:
1. `tls` + `tls_passthrough` renders a mixed port — plain listen,
`ssl_preread on`, `proxy_pass $stream_tls_target`, PROXY protocol out,
`access_log off`; the internal terminating server has the certificate phases
and `set_real_ip_from`, the internal passthrough server prereads and terminates
nothing;
2. two listens on one address needing different server blocks are rejected,
including a port range overlapping a single port;
3. a pure passthrough listen renders without `ssl`, with `ssl_preread on`,
no termination or re-encryption;
4. terminating and plain listens keep their own block, and `ssl_preread`
does not leak into them; a pure passthrough listen pays no mixed-mode hop and
creates no internal sockets;
5. passthrough combines with `proxy_protocol_to_upstream`;
6. mixed mode end to end on a real generated config: one port,
`admin.apisix.dev` reaches a TLS backend untouched (backend's own certificate),
`test.com` is terminated by the gateway and proxied plaintext to a plain
backend. Both routes are pinned to `server_port: 9100`, which only matches if
the router reads the address the PROXY header carried rather than the unix
socket's own;
7. an `ip-restriction` whitelist on the terminating route passes and a
blacklist denies — the internal hop reports the real client address, not
`unix:`.
`t/stream-node/` and `t/stream-plugin/` were run file by file against the
merge base with a clean etcd between runs: identical results on both sides,
plus the new file passing.
#### Which issue(s) this PR fixes:
Ported from api7/api7-ee-3-gateway#2173, where it has already been reviewed.
### Checklist
- [x] I have explained the need for this PR and the problem it solves
- [x] I have explained the changes or the new features added to this PR
- [x] I have added tests corresponding to this change
- [x] I have updated the documentation to reflect this change
- [x] I have verified that this change is backward compatible (If not,
please discuss on the [APISIX mailing
list](https://github.com/apache/apisix/tree/master#community) first)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]