This is an automated email from the ASF dual-hosted git repository.

AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix.git


The following commit(s) were added to refs/heads/master by this push:
     new 57b401dab feat(stream): support TLS passthrough on the stream proxy 
(#13912)
57b401dab is described below

commit 57b401dab32351178404d693ea658989ce52fca5
Author: AlinsRan <[email protected]>
AuthorDate: Mon Sep 7 14:51:42 2026 +0800

    feat(stream): support TLS passthrough on the stream proxy (#13912)
---
 apisix/cli/ngx_tpl.lua               |  91 ++++++-
 apisix/cli/ops.lua                   | 143 +++++++++--
 apisix/cli/schema.lua                |   3 +
 apisix/init.lua                      |  55 ++++-
 apisix/schema_def.lua                |   7 +
 apisix/ssl.lua                       |  12 +-
 apisix/stream/router/ip_port.lua     |   2 +-
 apisix/upstream.lua                  |   9 +
 conf/config.yaml.example             |  20 ++
 docs/en/latest/stream-proxy.md       |  85 +++++++
 docs/zh/latest/stream-proxy.md       |  85 +++++++
 t/APISIX.pm                          |   9 +-
 t/cli/test_stream_tls_passthrough.sh | 449 +++++++++++++++++++++++++++++++++++
 t/stream-node/tls-passthrough.t      | 302 +++++++++++++++++++++++
 14 files changed, 1248 insertions(+), 24 deletions(-)

diff --git a/apisix/cli/ngx_tpl.lua b/apisix/cli/ngx_tpl.lua
index 2b4a75231..42833de45 100644
--- a/apisix/cli/ngx_tpl.lua
+++ b/apisix/cli/ngx_tpl.lua
@@ -234,14 +234,27 @@ stream {
     }
 
     {% for _, server_group in ipairs(stream_proxy.servers or {}) do %}
+    {% if server_group.tls_mixed then %}
+    upstream {* server_group.tls_terminate_up *} {
+        server unix:{* server_group.tls_terminate_sock *};
+    }
+
+    upstream {* server_group.tls_passthrough_up *} {
+        server unix:{* server_group.tls_passthrough_sock *};
+    }
+    {% end %}
     server {
         {% for _, item in ipairs(server_group.tcp) do %}
-        listen {*item.addr*} {% if item.tls then %} ssl {% end %} {% if 
enable_reuseport then %} reuseport {% end %} {% if item.proxy_protocol then %} 
proxy_protocol {% end %};
+        listen {*item.addr*} {% if item.tls and not server_group.tls_mixed 
then %} ssl {% end %} {% if enable_reuseport then %} reuseport {% end %} {% if 
item.proxy_protocol then %} proxy_protocol {% end %};
         {% end %}
         {% for _, addr in ipairs(server_group.udp) do %}
         listen {*addr*} udp {% if enable_reuseport then %} reuseport {% end %};
         {% end %}
 
+        {% if server_group.tls_passthrough then %}
+        ssl_preread on;
+        {% end %}
+
         {% if server_group.tcp_enable_ssl then %}
         ssl_certificate      {* ssl.ssl_cert *};
         ssl_certificate_key  {* ssl.ssl_cert_key *};
@@ -255,12 +268,43 @@ stream {
         }
         {% end %}
 
+        {% if server_group.tls_mixed then %}
+        # carries the client address across the internal hop
+        proxy_protocol on;
+        access_log off;
+
+        set $stream_tls_target "";
+
+        preread_by_lua_block {
+            apisix.stream_tls_route_phase("{* server_group.tls_terminate_up 
*}",
+                                          "{* server_group.tls_passthrough_up 
*}")
+        }
+
+        proxy_pass $stream_tls_target;
+    }
+
+    # internal: terminates the handshake
+    server {
+        listen unix:{* server_group.tls_terminate_sock *} ssl proxy_protocol;
+        set_real_ip_from unix:;
+
+        ssl_certificate      {* ssl.ssl_cert *};
+        ssl_certificate_key  {* ssl.ssl_cert_key *};
+
+        ssl_client_hello_by_lua_block {
+            apisix.ssl_client_hello_phase()
+        }
+
+        ssl_certificate_by_lua_block {
+            apisix.ssl_phase()
+        }
+
         {% if server_group.proxy_protocol_to_upstream then %}
         proxy_protocol on;
         {% end %}
 
         preread_by_lua_block {
-            apisix.stream_preread_phase()
+            apisix.stream_preread_phase(nil, true)
         }
 
         proxy_pass apisix_backend;
@@ -275,6 +319,49 @@ stream {
             apisix.stream_log_phase()
         }
     }
+
+    # internal: forwards the stream untouched, prereading the same ClientHello 
again
+    server {
+        listen unix:{* server_group.tls_passthrough_sock *} proxy_protocol;
+        set_real_ip_from unix:;
+        ssl_preread on;
+
+        {% if server_group.proxy_protocol_to_upstream then %}
+        proxy_protocol on;
+        {% end %}
+
+        preread_by_lua_block {
+            apisix.stream_preread_phase(true, true)
+        }
+
+        proxy_pass apisix_backend;
+
+        log_by_lua_block {
+            apisix.stream_log_phase()
+        }
+    }
+    {% else %}
+        {% if server_group.proxy_protocol_to_upstream then %}
+        proxy_protocol on;
+        {% end %}
+
+        preread_by_lua_block {
+            apisix.stream_preread_phase({% if server_group.tls_passthrough 
then %}true{% end %})
+        }
+
+        proxy_pass apisix_backend;
+
+        {% if use_apisix_base and not server_group.tls_passthrough then %}
+        set $upstream_sni "apisix_backend";
+        proxy_ssl_server_name on;
+        proxy_ssl_name $upstream_sni;
+        {% end %}
+
+        log_by_lua_block {
+            apisix.stream_log_phase()
+        }
+    }
+    {% end %}
     {% end %}
 }
 {% end %}
diff --git a/apisix/cli/ops.lua b/apisix/cli/ops.lua
index 0f6a4fae6..6b0f6ee62 100644
--- a/apisix/cli/ops.lua
+++ b/apisix/cli/ops.lua
@@ -131,6 +131,50 @@ local function validate_port_or_range(port_entry)
 end
 
 
+-- Split a stream_proxy.tcp `addr` into its address and port range. Returns 
nil for
+-- the forms validate_port_or_range leaves to nginx.
+local function parse_listen_addr(addr)
+    if type(addr) == "number" then
+        return "", addr, addr
+    end
+
+    local ip, port_part
+    if str_find(addr, "[", 1, true) then
+        local bracket_end = str_find(addr, "]", 1, true)
+        if not (bracket_end and str_sub(addr, bracket_end + 1, bracket_end + 
1) == ":") then
+            return nil
+        end
+        ip = str_sub(addr, 1, bracket_end)
+        port_part = str_sub(addr, bracket_end + 2)
+    else
+        local colon_pos = str_find(addr, ":", 1, true)
+        if colon_pos then
+            ip = str_sub(addr, 1, colon_pos - 1)
+            port_part = str_sub(addr, colon_pos + 1)
+        else
+            ip = ""
+            port_part = addr
+        end
+    end
+
+    if ip == "0.0.0.0" then
+        ip = ""
+    end
+
+    local start_str, end_str = port_part:match("^(%d+)%-(%d+)$")
+    if start_str then
+        return ip, tonumber(start_str), tonumber(end_str)
+    end
+
+    local port = tonumber(port_part)
+    if not port then
+        return nil
+    end
+
+    return ip, port, port
+end
+
+
 local function help()
     print([[
 Usage: apisix [action] <argument>
@@ -637,23 +681,23 @@ Please modify "admin_key" in conf/config.yaml .
         end
     end
 
-    -- Split stream listens into nginx server blocks. 
`proxy_protocol_to_upstream`
-    -- (sending the PROXY protocol toward the upstream) is a server-level 
directive,
-    -- so TCP listens that enable it need a dedicated server block. The 
accept-side
-    -- `proxy_protocol` and `ssl` are per-listen directives and coexist in one 
block.
-    -- Per-listen settings fall back to the global `proxy_protocol` options; 
UDP never
-    -- sends the PROXY protocol upstream and always joins the plain block.
+    -- Split stream listens into nginx server blocks, one per combination of 
the
+    -- server-level directives a listen needs: `proxy_protocol on` (PROXY 
protocol to
+    -- the upstream) and the TLS mode -- plain (`listen ... [ssl]`), 
passthrough
+    -- (`ssl_preread on`) or mixed (`ssl_preread on` plus the two internal 
servers the
+    -- route picks between). The accept-side `proxy_protocol` and `ssl` are 
per-listen
+    -- and coexist in one block. Per-listen settings fall back to the global
+    -- `proxy_protocol` options; UDP always joins the plain block.
     if enable_stream and yaml_conf.apisix.stream_proxy then
         local stream_proxy = yaml_conf.apisix.stream_proxy
         local pp = yaml_conf.apisix.proxy_protocol or {}
         local plain = {
             tcp = {}, udp = stream_proxy.udp or {},
             proxy_protocol_to_upstream = false, tcp_enable_ssl = false,
+            tls_passthrough = false, tls_mixed = false,
         }
-        local to_upstream = {
-            tcp = {}, udp = {},
-            proxy_protocol_to_upstream = true, tcp_enable_ssl = false,
-        }
+        local servers = {plain}
+        local group_by_key = {["false|plain"] = plain}
         for _, item in ipairs(stream_proxy.tcp or {}) do
             if item.proxy_protocol == nil then
                 item.proxy_protocol = pp.enable_tcp_pp
@@ -662,18 +706,83 @@ Please modify "admin_key" in conf/config.yaml .
             if up == nil then
                 up = pp.enable_tcp_pp_to_upstream
             end
-            local group = up and to_upstream or plain
-            if item.tls then
+            up = up and true or false
+            local mode = "plain"
+            if item.tls_passthrough then
+                mode = item.tls and "mixed" or "passthrough"
+            end
+            if mode == "mixed" and up then
+                -- The mixed block reaches its internal servers over a unix 
socket,
+                -- and nginx builds the upstream PROXY protocol header from 
that
+                -- socket: the destination comes out as `unix:/... 0`, which 
is not
+                -- a valid TCP4/TCP6 address and strict backends reject it. A
+                -- dedicated tls or tls_passthrough listen has no internal hop 
and
+                -- writes a correct header.
+                util.die("invalid stream_proxy.tcp entry: `", 
tostring(item.addr),
+                         "` can not combine proxy_protocol_to_upstream with a 
mixed ",
+                         "`tls` + `tls_passthrough` listen; use a dedicated 
listen ",
+                         "for either mode\n")
+            end
+
+            local key = tostring(up) .. "|" .. mode
+            local group = group_by_key[key]
+            if not group then
+                group = {
+                    tcp = {}, udp = {},
+                    proxy_protocol_to_upstream = up, tcp_enable_ssl = false,
+                    tls_passthrough = mode ~= "plain", tls_mixed = mode == 
"mixed",
+                }
+                group_by_key[key] = group
+                table_insert(servers, group)
+            end
+            -- in mixed mode the internal server terminates, so the listen 
stays plain
+            if item.tls and mode == "plain" then
                 group.tcp_enable_ssl = true
             end
             table_insert(group.tcp, item)
         end
-        local servers = {}
-        if #plain.tcp > 0 or #plain.udp > 0 then
-            table_insert(servers, plain)
+        if #plain.tcp == 0 and #plain.udp == 0 then
+            table_remove(servers, 1)
+        end
+        -- Two listens needing different server blocks can not share an 
address: nginx
+        -- refuses to start with reuseport on, and silently keeps only the 
first without.
+        local addr_owner = {}
+        for idx, group in ipairs(servers) do
+            for _, item in ipairs(group.tcp) do
+                local ip, first, last = parse_listen_addr(item.addr)
+                if ip then
+                    for port = first, last do
+                        local key = ip .. "|" .. port
+                        if addr_owner[key] and addr_owner[key] ~= idx then
+                            util.die("invalid stream_proxy.tcp entry: `", 
tostring(item.addr),
+                                     "` collides with an earlier entry on the 
same address ",
+                                     "that needs a different nginx server 
block; one address ",
+                                     "can only have one TLS mode and one ",
+                                     "proxy_protocol_to_upstream setting\n")
+                        end
+                        addr_owner[key] = idx
+                    end
+                end
+            end
         end
-        if #to_upstream.tcp > 0 then
-            table_insert(servers, to_upstream)
+
+        -- Short names on purpose: the kernel caps a unix socket path at ~108 
bytes and
+        -- it is rooted at the user-chosen apisix home.
+        for idx, group in ipairs(servers) do
+            if group.tls_mixed then
+                group.tls_terminate_sock = env.apisix_home .. "/logs/stls-t" 
.. idx .. ".sock"
+                group.tls_passthrough_sock = env.apisix_home .. "/logs/stls-p" 
.. idx .. ".sock"
+                group.tls_terminate_up = "apisix_stream_tls_terminate_" .. idx
+                group.tls_passthrough_up = "apisix_stream_tls_passthrough_" .. 
idx
+                for _, path in ipairs({group.tls_terminate_sock, 
group.tls_passthrough_sock}) do
+                    if #path > 100 then
+                        util.die("mixed TLS listens need internal unix sockets 
under ",
+                                 env.apisix_home, "/logs, but `", path, "` is 
", #path,
+                                 " bytes, over the ~108 byte limit; install 
APISIX ",
+                                 "under a shorter path\n")
+                    end
+                end
+            end
         end
         stream_proxy.servers = servers
     end
diff --git a/apisix/cli/schema.lua b/apisix/cli/schema.lua
index 25c4cc507..e52453e56 100644
--- a/apisix/cli/schema.lua
+++ b/apisix/cli/schema.lua
@@ -178,6 +178,9 @@ local config_schema = {
                                             tls = {
                                                 type = "boolean",
                                             },
+                                            tls_passthrough = {
+                                                type = "boolean",
+                                            },
                                             proxy_protocol = {
                                                 type = "boolean",
                                             },
diff --git a/apisix/init.lua b/apisix/init.lua
index 049c15ad9..eb96f7a29 100644
--- a/apisix/init.lua
+++ b/apisix/init.lua
@@ -1389,17 +1389,68 @@ function _M.stream_init_worker()
 end
 
 
-function _M.stream_preread_phase()
+-- Preread phase of a mixed TLS listen: pick which internal server gets the
+-- connection. Plugins and the log phase run there, not here.
+function _M.stream_tls_route_phase(terminate_upstream, passthrough_upstream)
     local ngx_ctx = ngx.ctx
     local api_ctx = core.tablepool.fetch("api_ctx", 0, 32)
     ngx_ctx.api_ctx = api_ctx
+    -- nothing was terminated here, so the SNI can only come from the 
ClientHello
+    api_ctx.tls_passthrough = true
 
-    if not verify_tls_client(api_ctx) then
+    core.ctx.set_vars_meta(api_ctx)
+
+    local ok, err = router.router_stream.match(api_ctx)
+    if not ok then
+        core.log.error(err)
+    end
+
+    -- an unmatched connection goes to the terminating server, which reports 
the miss
+    local matched_route = api_ctx.matched_route
+    local target = terminate_upstream
+    if matched_route and matched_route.value.tls_passthrough then
+        target = passthrough_upstream
+    end
+    ngx_var.stream_tls_target = target
+
+    core.log.info("stream tls route: sni: ", 
api_ctx.var.ssl_preread_server_name,
+                  ", target: ", target)
+
+    core.ctx.release_vars(api_ctx)
+    core.tablepool.release("api_ctx", api_ctx)
+    ngx_ctx.api_ctx = nil
+end
+
+
+-- `tls_passthrough` is set by the template on listens running `ssl_preread 
on`:
+-- no local handshake, so no client certificate to verify and the SNI is 
prereaded.
+function _M.stream_preread_phase(tls_passthrough, behind_mixed_hop)
+    local ngx_ctx = ngx.ctx
+    local api_ctx = core.tablepool.fetch("api_ctx", 0, 32)
+    ngx_ctx.api_ctx = api_ctx
+    api_ctx.tls_passthrough = tls_passthrough
+
+    if not tls_passthrough and not verify_tls_client(api_ctx) then
         return ngx_exit(1)
     end
 
     core.ctx.set_vars_meta(api_ctx)
 
+    -- On the internal servers of a mixed listen the connection arrives over a 
unix
+    -- socket, so $server_addr/$server_port describe that socket rather than 
the port
+    -- the client reached. The real one is in the PROXY protocol header, and 
routes
+    -- match on it.
+    if behind_mixed_hop then
+        local addr = ngx_var.proxy_protocol_server_addr
+        if addr and addr ~= "" then
+            api_ctx.var.server_addr = addr
+        end
+        local port = ngx_var.proxy_protocol_server_port
+        if port and port ~= "" then
+            api_ctx.var.server_port = port
+        end
+    end
+
     local ok, err = router.router_stream.match(api_ctx)
     if not ok then
         core.log.error(err)
diff --git a/apisix/schema_def.lua b/apisix/schema_def.lua
index 3687cbcf5..85dd40905 100644
--- a/apisix/schema_def.lua
+++ b/apisix/schema_def.lua
@@ -1050,6 +1050,13 @@ _M.stream_route = {
             type = "string",
             pattern = host_def_pat,
         },
+        tls_passthrough = {
+            description = "forward the TLS stream to the upstream untouched 
instead of "
+                          .. "terminating it here; only consulted on a mixed 
listen, one "
+                          .. "with both tls and tls_passthrough set",
+            type = "boolean",
+            default = false,
+        },
         upstream = upstream_schema,
         upstream_id = id_schema,
         service_id = id_schema,
diff --git a/apisix/ssl.lua b/apisix/ssl.lua
index f8a1b2e8a..29d0e635e 100644
--- a/apisix/ssl.lua
+++ b/apisix/ssl.lua
@@ -23,6 +23,7 @@ local ngx_ssl_client = require("ngx.ssl.clienthello")
 local str_lower = string.lower
 local str_byte = string.byte
 local ngx_sub = ngx.re.sub
+local ngx_var = ngx.var
 
 local cert_cache = core.lrucache.new {
     ttl = 3600, count = 1024,
@@ -36,10 +37,19 @@ local pkey_cache = core.lrucache.new {
 local _M = {}
 
 
-function _M.server_name(clienthello)
+-- `preread` takes the SNI from ngx_stream_ssl_preread_module: under TLS 
passthrough
+-- this worker never performs the handshake, so ngx_ssl.server_name() has 
nothing.
+function _M.server_name(clienthello, preread)
     local sni, err
     if clienthello then
         sni, err = ngx_ssl_client.get_client_hello_server_name()
+    elseif preread then
+        sni = ngx_var.ssl_preread_server_name
+        if sni == "" then
+            -- defined but empty when the ClientHello carried no SNI; "" is 
truthy
+            -- and would skip the fallback below
+            sni = nil
+        end
     else
         sni, err = ngx_ssl.server_name()
     end
diff --git a/apisix/stream/router/ip_port.lua b/apisix/stream/router/ip_port.lua
index 1757824f9..ce0a81910 100644
--- a/apisix/stream/router/ip_port.lua
+++ b/apisix/stream/router/ip_port.lua
@@ -166,7 +166,7 @@ do
             service_ver = cur_svc_ver
         end
 
-        local sni = apisix_ssl.server_name()
+        local sni = apisix_ssl.server_name(nil, api_ctx.tls_passthrough)
         if sni and tls_router then
             local sni_rev = sni:reverse()
 
diff --git a/apisix/upstream.lua b/apisix/upstream.lua
index 7ed61faf9..99e1e857d 100644
--- a/apisix/upstream.lua
+++ b/apisix/upstream.lua
@@ -328,6 +328,15 @@ end
 
 
 function _M.set_by_route(route, api_ctx)
+    -- Ahead of the traffic-split short circuit below so its inline upstream is
+    -- covered too. A second handshake would send the client's ClientHello as 
payload.
+    if api_ctx.tls_passthrough then
+        local passthrough_up = api_ctx.upstream_conf or 
api_ctx.matched_upstream
+        if passthrough_up and passthrough_up.scheme == "tls" then
+            return 503, "upstream scheme `tls` can not be used on a 
tls_passthrough listen"
+        end
+    end
+
     if api_ctx.upstream_conf then
         -- upstream_conf has been set by traffic-split plugin
         return
diff --git a/conf/config.yaml.example b/conf/config.yaml.example
index 6f6c791ea..b201c97fb 100644
--- a/conf/config.yaml.example
+++ b/conf/config.yaml.example
@@ -95,6 +95,26 @@ apisix:
   #       proxy_protocol_to_upstream: true  # Send the PROXY protocol to the 
upstream on this
   #                                         # port only. Both override the 
global
   #                                         # proxy_protocol.enable_tcp_pp* 
defaults.
+  #     - addr: 9500
+  #       tls_passthrough: true      # Forward the TLS stream untouched, 
picking the upstream from
+  #                                  # the SNI in the prereaded ClientHello. 
The backend terminates
+  #                                  # the handshake, so payload-inspecting 
stream plugins
+  #                                  # (mqtt-proxy, xrpc, redis) and client 
certificate verification
+  #                                  # do not apply. Every connection on this 
port is passed
+  #                                  # through, whatever the stream_route says.
+  #     - addr: 9501
+  #       tls: true                  # Both flags open a mixed port: the 
listen only prereads, and
+  #       tls_passthrough: true      # each connection follows 
`tls_passthrough` on the stream_route
+  #                                  # it matches (default false). Costs one 
internal hop, so prefer
+  #                                  # a dedicated tls_passthrough port when 
the whole port passes
+  #                                  # through. That hop runs over unix 
sockets under logs/, and
+  #                                  # announces the client in a PROXY 
protocol header, so a local
+  #                                  # user able to connect to them could 
forge it. Keep logs/ off
+  #                                  # limits to untrusted local users, as it 
already has to be for
+  #                                  # stream_worker_events.sock.
+  #                                  # proxy_protocol_to_upstream is rejected 
on a mixed port: the
+  #                                  # upstream PROXY header would carry the 
internal unix socket
+  #                                  # as its destination. Use a dedicated 
listen instead.
   #     - "2000-2100"              # port range (nginx native support)
   #     - addr: "3000-3100"        # port range in table form
   #     - addr: "127.0.0.1:4000-4100"  # address with port range in table form
diff --git a/docs/en/latest/stream-proxy.md b/docs/en/latest/stream-proxy.md
index b87b42ddd..e7152058d 100644
--- a/docs/en/latest/stream-proxy.md
+++ b/docs/en/latest/stream-proxy.md
@@ -242,6 +242,91 @@ By setting the `scheme` to `tls`, APISIX will do TLS 
handshake with the upstream
 
 When the client is also speaking TLS over TCP, the SNI from the client will 
pass through to the upstream. Otherwise, a dummy SNI `apisix_backend` will be 
used.
 
+## TLS passthrough
+
+The two sections above both terminate the client's TLS handshake at APISIX. 
With `tls_passthrough`, APISIX instead forwards the encrypted stream to the 
upstream untouched, and still picks the upstream from the SNI, which it reads 
out of the prereaded `ClientHello` (`ssl_preread on`) rather than out of a 
handshake it performed itself:
+
+```yaml
+apisix:
+  proxy_mode: http&stream
+  stream_proxy:
+    tcp:
+      - addr: 9100
+        tls_passthrough: true
+```
+
+The upstream terminates the handshake, so on such a port:
+
+- payload-inspecting stream plugins (`mqtt-proxy`, `xrpc`, `redis`) have 
nothing to read, and gateway mTLS does not apply — client certificate 
verification moves to the upstream;
+- an upstream with `"scheme": "tls"` is rejected with a `503`, because a 
second handshake would send the client's `ClientHello` to the upstream as 
payload.
+
+Routing is otherwise unchanged, so a stream route matches by SNI exactly as it 
does for a terminating port:
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/stream_routes/1 -H "X-API-KEY: 
$admin_key" -X PUT -d '
+{
+    "sni": "a.test.com",
+    "upstream": {
+        "nodes": {
+            "127.0.0.1:5991": 1
+        },
+        "type": "roundrobin"
+    }
+}'
+```
+
+### Deciding per route on a mixed port
+
+Setting both flags on one listen opens a **mixed** port, where each connection 
is terminated or passed through according to `tls_passthrough` on the stream 
route it matches (a boolean, `false` by default):
+
+```yaml
+apisix:
+  proxy_mode: http&stream
+  stream_proxy:
+    tcp:
+      - addr: 9100
+        tls: true
+        tls_passthrough: true
+```
+
+```shell
+# passed through to the upstream, which terminates the handshake
+curl http://127.0.0.1:9180/apisix/admin/stream_routes/1 -H "X-API-KEY: 
$admin_key" -X PUT -d '
+{
+    "sni": "a.test.com",
+    "tls_passthrough": true,
+    "upstream": {
+        "nodes": {
+            "127.0.0.1:5991": 1
+        },
+        "type": "roundrobin"
+    }
+}'
+
+# terminated by APISIX, using the certificate configured for this SNI
+curl http://127.0.0.1:9180/apisix/admin/stream_routes/2 -H "X-API-KEY: 
$admin_key" -X PUT -d '
+{
+    "sni": "b.test.com",
+    "upstream": {
+        "nodes": {
+            "127.0.0.1:5992": 1
+        },
+        "type": "roundrobin"
+    }
+}'
+```
+
+The route flag comes from etcd, so moving a service between the two modes 
needs no configuration change or restart.
+
+A 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 live 
in internal servers reachable over unix sockets under `logs/`, and the preread 
phase of the public listen picks between them. Note that:
+
+- only mixed ports pay that extra hop; a `tls: true` or `tls_passthrough: 
true` port keeps its direct path;
+- the client address crosses the hop in a PROXY protocol header restored with 
`set_real_ip_from unix:`, so route matching, stream plugins and logs see the 
real peer. A local user able to connect to those sockets could forge that 
header, so keep `logs/` off limits to untrusted local users — as it already has 
to be for `stream_worker_events.sock`;
+- `proxy_protocol_to_upstream` cannot be used on a mixed port and is rejected 
at startup: nginx builds the upstream PROXY protocol header from the socket the 
connection arrived on, which for the internal servers is the unix socket, 
producing an invalid `PROXY TCP4 <client> unix:/... <port> 0`. Use a dedicated 
`tls` or `tls_passthrough` listen, which has no internal hop and writes a 
correct header;
+- `apisix_stream_metrics_zone` counts nginx sessions and has no per-server 
switch, so a mixed port counts each client connection twice.
+
+Two `stream_proxy.tcp` entries on the same address must agree on their TLS 
mode and on `proxy_protocol_to_upstream`; otherwise they would need different 
nginx `server` blocks on one address, and APISIX rejects the configuration at 
startup.
+
 ## PROXY protocol
 
 APISIX can accept the [PROXY 
protocol](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt) on TCP 
stream ports and forward it to the upstream.
diff --git a/docs/zh/latest/stream-proxy.md b/docs/zh/latest/stream-proxy.md
index 55636184d..bf1cb3ec8 100644
--- a/docs/zh/latest/stream-proxy.md
+++ b/docs/zh/latest/stream-proxy.md
@@ -233,6 +233,91 @@ curl http://127.0.0.1:9180/apisix/admin/stream_routes/1 -H 
"X-API-KEY: $admin_ke
 
 当客户端也使用基于 TCP 的 TLS 上游时,客户端发送的 SNI 将传递给上游。否则,将使用一个假的 SNI `apisix_backend`。
 
+## TLS 透传
+
+上面两节都会在 APISIX 上终止客户端的 TLS 握手。设置 `tls_passthrough` 后,APISIX 
会把加密的流原封不动地转发给上游,同时仍然根据 SNI 选择上游——该 SNI 来自预读到的 `ClientHello`(`ssl_preread 
on`),而不是 APISIX 自己完成的握手:
+
+```yaml
+apisix:
+  proxy_mode: http&stream
+  stream_proxy:
+    tcp:
+      - addr: 9100
+        tls_passthrough: true
+```
+
+握手由上游完成,因此在这样的端口上:
+
+- 需要检查负载的 stream 插件(`mqtt-proxy`、`xrpc`、`redis`)读不到任何内容,网关侧的 mTLS 
也不再适用——客户端证书校验转移到上游;
+- `"scheme": "tls"` 的上游会被拒绝并返回 `503`,因为再握手一次会把客户端的 `ClientHello` 当作负载发给上游。
+
+路由行为没有变化,stream route 依然按 SNI 匹配,与终止 TLS 的端口一致:
+
+```shell
+curl http://127.0.0.1:9180/apisix/admin/stream_routes/1 -H "X-API-KEY: 
$admin_key" -X PUT -d '
+{
+    "sni": "a.test.com",
+    "upstream": {
+        "nodes": {
+            "127.0.0.1:5991": 1
+        },
+        "type": "roundrobin"
+    }
+}'
+```
+
+### 在混合端口上按路由决定
+
+在同一个 listen 上同时设置两个开关会开启**混合**端口:每条连接是终止还是透传,取决于它匹配到的 stream route 上的 
`tls_passthrough`(布尔值,默认为 `false`):
+
+```yaml
+apisix:
+  proxy_mode: http&stream
+  stream_proxy:
+    tcp:
+      - addr: 9100
+        tls: true
+        tls_passthrough: true
+```
+
+```shell
+# 透传给上游,由上游完成握手
+curl http://127.0.0.1:9180/apisix/admin/stream_routes/1 -H "X-API-KEY: 
$admin_key" -X PUT -d '
+{
+    "sni": "a.test.com",
+    "tls_passthrough": true,
+    "upstream": {
+        "nodes": {
+            "127.0.0.1:5991": 1
+        },
+        "type": "roundrobin"
+    }
+}'
+
+# 由 APISIX 终止,使用为该 SNI 配置的证书
+curl http://127.0.0.1:9180/apisix/admin/stream_routes/2 -H "X-API-KEY: 
$admin_key" -X PUT -d '
+{
+    "sni": "b.test.com",
+    "upstream": {
+        "nodes": {
+            "127.0.0.1:5992": 1
+        },
+        "type": "roundrobin"
+    }
+}'
+```
+
+路由上的该字段来自 etcd,因此在两种模式之间切换某个服务无需修改配置或重启。
+
+端口本身无法同时是两者:`ssl_preread on` 与 `listen ... ssl` 都是配置期指令,在同一个 server 
上握手会在预读阶段读到 `ClientHello` 之前就将其消耗掉。因此这两种行为被放到通过 `logs/` 下的 unix socket 访问的内部 
server 中,由公开 listen 的预读阶段在两者之间做选择。需要注意:
+
+- 只有混合端口会付出这一跳的代价;`tls: true` 或 `tls_passthrough: true` 的端口仍走直连路径;
+- 客户端地址通过 PROXY 协议头跨过这一跳,并由 `set_real_ip_from unix:` 还原,因此路由匹配、stream 
插件和日志看到的都是真实对端。能够连接这些 socket 的本地用户可以伪造该头部,因此要像对待 `stream_worker_events.sock` 
一样,不要让不受信任的本地用户访问 `logs/`;
+- 混合端口上不能使用 `proxy_protocol_to_upstream`,启动时会被拒绝:nginx 依据连接进入时的 socket 生成发往上游的 
PROXY 协议头,而内部 server 上那是一个 unix socket,生成的是 `PROXY TCP4 <client> unix:/... 
<port> 0` 这种非法内容。请改用专用的 `tls` 或 `tls_passthrough` 监听,它们没有内部跳转,生成的头是正确的;
+- `apisix_stream_metrics_zone` 统计的是 nginx session 且没有 per-server 
开关,因此混合端口上每条客户端连接会被统计两次。
+
+同一地址上的两个 `stream_proxy.tcp` 条目必须使用相同的 TLS 模式和相同的 
`proxy_protocol_to_upstream`;否则它们需要在同一地址上使用不同的 nginx `server` 块,APISIX 
会在启动时拒绝该配置。
+
 ## PROXY 协议
 
 APISIX 可以在 TCP stream 端口上接收 [PROXY 
协议](https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt),并将其转发给上游。
diff --git a/t/APISIX.pm b/t/APISIX.pm
index 91b6ef133..76dc94530 100644
--- a/t/APISIX.pm
+++ b/t/APISIX.pm
@@ -353,6 +353,9 @@ _EOC_
         if ($block->stream_sni) {
             $sni = '"' . $block->stream_sni . '"';
         }
+
+        # a bare `--- stream_tls_verify` section has an empty, false value
+        my $tls_verify = defined $block->stream_tls_verify ? "true" : "false";
         chomp $stream_tls_request;
 
         my $repeat = "1";
@@ -372,7 +375,7 @@ _EOC_
                             return
                         end
 
-                        sess, err = sock:sslhandshake(sess, $sni, false)
+                        sess, err = sock:sslhandshake(sess, $sni, $tls_verify)
                         if not sess then
                             ngx.say("failed to do SSL handshake: ", err)
                             return
@@ -591,6 +594,10 @@ $stream_config
     }
 }
 _EOC_
+        # the stream block lives in the main config here, so drop the block 
values
+        # or Test::Nginx renders a second, conflicting one
+        $block->set_value("stream_config");
+        $block->set_value("stream_server_config");
     }
 
     $block->set_value("main_config", $main_config);
diff --git a/t/cli/test_stream_tls_passthrough.sh 
b/t/cli/test_stream_tls_passthrough.sh
new file mode 100755
index 000000000..cc819b4ec
--- /dev/null
+++ b/t/cli/test_stream_tls_passthrough.sh
@@ -0,0 +1,449 @@
+#!/usr/bin/env bash
+
+#
+# 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.
+#
+
+. ./t/cli/common.sh
+
+# What lives here and not in t/stream-node/tls-passthrough.t: the Test::Nginx
+# framework 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 -- how
+# listens are grouped by TLS mode, what a passthrough or mixed block renders --
+# is unreachable from a .t. The mixed-mode run at the end is here for the same
+# reason: its internal unix-socket servers are generated by the template, and
+# hand-writing them in a .t would test the copy rather than the template.
+#
+# Everything a .t can reach -- the preread SNI selecting an upstream, the
+# mixed-mode phase picking its target, the `scheme: tls` refusal -- is asserted
+# there instead.
+
+# Print the stream server{} block that contains "listen <port>", isolating
+# it by brace-counting from each "server {".
+block_with_listen() {
+    awk -v port="$1" '
+    /server[ \t]*\{/ { depth = 0; block = ""; inblock = 1 }
+    inblock {
+        block = block $0 "\n"
+        depth += gsub(/\{/, "{")
+        depth -= gsub(/\}/, "}")
+        if (depth == 0) {
+            if (block ~ ("listen " port "[ ;]")) { printf "%s", block }
+            inblock = 0
+        }
+    }
+    ' conf/nginx.conf
+}
+
+# Print the stream server{} block whose listen line matches <regex>.
+block_with_listen_re() {
+    awk -v re="$1" '
+    /server[ \t]*\{/ { depth = 0; block = ""; inblock = 1 }
+    inblock {
+        block = block $0 "\n"
+        depth += gsub(/\{/, "{")
+        depth -= gsub(/\}/, "}")
+        if (depth == 0) {
+            if (block ~ re) { printf "%s", block }
+            inblock = 0
+        }
+    }
+    ' conf/nginx.conf
+}
+
+# Handshake against <port> with <sni>, validating the served chain against
+# <ca>. Prints the openssl transcript. Retried by the caller: the stream route
+# has to propagate from etcd to the stream worker first.
+tls_probe() {
+    local port="$1"
+    local sni="$2"
+    local ca="$3"
+    echo -e 'mmm' | timeout 3 openssl s_client -connect 127.0.0.1:"$port" \
+        -servername "$sni" -CAfile "$ca" -ign_eof 2>/dev/null
+}
+
+# Retry tls_probe under a 20s deadline until one transcript matches every
+# pattern given after the CA. All of them have to hold for the same handshake:
+# the route reaches the stream workers one at a time, so a probe answered by a
+# worker that has already synced says nothing about the next connection.
+tls_probe_until() {
+    local port="$1" sni="$2" ca="$3"
+    shift 3
+    local deadline=$(( $(date +%s) + 20 ))
+    { set +x; } 2>/dev/null
+    while [ "$(date +%s)" -lt "$deadline" ]; do
+        local out
+        out=$(tls_probe "$port" "$sni" "$ca")
+        local matched=1
+        local pattern
+        for pattern in "$@"; do
+            if ! echo "$out" | grep -q "$pattern"; then
+                matched=0
+                break
+            fi
+        done
+        if [ "$matched" -eq 1 ]; then
+            set -x
+            return 0
+        fi
+        sleep 0.3
+    done
+    set -x
+    return 1
+}
+
+# === `tls` + `tls_passthrough` on one listen is mixed mode ===
+# The port itself can not be both, so it is opened for preread only and the two
+# real behaviours move into internal servers the preread phase picks between.
+echo '
+apisix:
+  proxy_mode: "http&stream"
+  stream_proxy:
+    tcp:
+      - addr: 9103
+        tls: true
+        tls_passthrough: true
+' > conf/config.yaml
+make init
+
+if block_with_listen 9103 | grep -E "listen 9103[ ;].*ssl" > /dev/null; then
+    echo "failed: a mixed listen must stay plain so ssl_preread can see the 
ClientHello"
+    exit 1
+fi
+if ! block_with_listen 9103 | grep -E "ssl_preread on;" > /dev/null; then
+    echo "failed: a mixed listen must enable ssl_preread"
+    exit 1
+fi
+if ! block_with_listen 9103 | grep -E "proxy_pass \\\$stream_tls_target;" > 
/dev/null; then
+    echo "failed: a mixed listen must route to the target the preread phase 
picked"
+    exit 1
+fi
+if ! block_with_listen 9103 | grep -E "^\s*proxy_protocol on;" > /dev/null; 
then
+    echo "failed: a mixed listen must announce the client across the internal 
hop"
+    exit 1
+fi
+
+term_block=$(block_with_listen_re "listen unix:.*stls-t")
+pass_block=$(block_with_listen_re "listen unix:.*stls-p")
+if ! echo "$term_block" | grep -E "listen unix:.*stls-t.*ssl proxy_protocol;" 
> /dev/null; then
+    echo "failed: the internal terminating server should accept ssl over PROXY 
protocol"
+    exit 1
+fi
+if ! echo "$term_block" | grep -E "ssl_certificate_by_lua_block" > /dev/null; 
then
+    echo "failed: the internal terminating server should run the certificate 
phase"
+    exit 1
+fi
+if ! echo "$term_block" | grep -E "set_real_ip_from unix:;" > /dev/null; then
+    echo "failed: the internal terminating server should restore the client 
address"
+    exit 1
+fi
+if ! echo "$pass_block" | grep -E "ssl_preread on;" > /dev/null; then
+    echo "failed: the internal passthrough server should preread the 
ClientHello again"
+    exit 1
+fi
+if echo "$pass_block" | grep -E "ssl_certificate_by_lua_block" > /dev/null; 
then
+    echo "failed: the internal passthrough server must not terminate anything"
+    exit 1
+fi
+if ! echo "$pass_block" | grep -E "set_real_ip_from unix:;" > /dev/null; then
+    echo "failed: the internal passthrough server should restore the client 
address"
+    exit 1
+fi
+if ! block_with_listen 9103 | grep -E "^\s*access_log off;" > /dev/null; then
+    echo "failed: the mixed preread block should not log the connection a 
second time"
+    exit 1
+fi
+echo "passed: tls + tls_passthrough renders a mixed-mode port"
+
+# === Two listens on one address that need different server blocks are 
rejected ===
+# nginx refuses to start over this with reuseport on, and silently keeps only 
the
+# first server with it off.
+echo '
+apisix:
+  proxy_mode: "http&stream"
+  stream_proxy:
+    tcp:
+      - 9100
+      - addr: 9100
+        tls_passthrough: true
+' > conf/config.yaml
+
+out=$(make init 2>&1 || true)
+if ! echo "$out" | grep -q "collides with an earlier entry"; then
+    echo "failed: the same address in two different server blocks should be 
rejected"
+    exit 1
+fi
+
+# The same holds for a port range overlapping a single port.
+echo '
+apisix:
+  proxy_mode: "http&stream"
+  stream_proxy:
+    tcp:
+      - "9100-9110"
+      - addr: 9105
+        tls_passthrough: true
+' > conf/config.yaml
+
+out=$(make init 2>&1 || true)
+if ! echo "$out" | grep -q "collides with an earlier entry"; then
+    echo "failed: a range overlapping another group should be rejected"
+    exit 1
+fi
+
+# Same address, same group is untouched.
+echo '
+apisix:
+  proxy_mode: "http&stream"
+  stream_proxy:
+    tcp:
+      - addr: 9100
+        tls_passthrough: true
+      - addr: 9101
+        tls_passthrough: true
+' > conf/config.yaml
+make init
+echo "passed: colliding listens across server blocks are rejected"
+
+# === A passthrough listen renders without ssl, with ssl_preread on ===
+echo '
+apisix:
+  proxy_mode: "http&stream"
+  stream_proxy:
+    tcp:
+      - 9100
+      - addr: 9101
+        tls: true
+      - addr: 9102
+        tls_passthrough: true
+    udp:
+      - 9200
+' > conf/config.yaml
+make init
+
+if block_with_listen 9102 | grep -E "listen 9102[ ;].*ssl" > /dev/null; then
+    echo "failed: a passthrough listen must not carry the ssl flag"
+    exit 1
+fi
+if ! block_with_listen 9102 | grep -E "ssl_preread on;" > /dev/null; then
+    echo "failed: a passthrough listen must enable ssl_preread"
+    exit 1
+fi
+if ! block_with_listen 9102 | grep -E "apisix.stream_preread_phase\(true\)" > 
/dev/null; then
+    echo "failed: a passthrough listen must tell the preread phase it is 
passthrough"
+    exit 1
+fi
+if block_with_listen 9102 | grep -E 
"ssl_certificate_by_lua_block|proxy_ssl_server_name" > /dev/null; then
+    echo "failed: a passthrough listen must not terminate or re-encrypt TLS"
+    exit 1
+fi
+echo "passed: passthrough listen renders ssl_preread without termination"
+
+# === Terminating and plain listens are untouched, and keep their own block ===
+if ! block_with_listen 9101 | grep -E "listen 9101[ ;].*ssl" > /dev/null; then
+    echo "failed: a tls listen should still terminate"
+    exit 1
+fi
+if ! block_with_listen 9101 | grep -E "ssl_certificate_by_lua_block" > 
/dev/null; then
+    echo "failed: a tls listen should still run the certificate phase"
+    exit 1
+fi
+if block_with_listen 9101 | grep -E "ssl_preread on;" > /dev/null; then
+    echo "failed: ssl_preread must not leak into the terminating block"
+    exit 1
+fi
+if ! block_with_listen 9101 | grep -E "listen 9100[ ;]" > /dev/null; then
+    echo "failed: plain and tls listens should share one block"
+    exit 1
+fi
+if block_with_listen 9102 | grep -E "listen 9100[ ;]|listen 9200 udp" > 
/dev/null; then
+    echo "failed: the passthrough listen needs a dedicated server block"
+    exit 1
+fi
+if block_with_listen 9102 | grep -E "proxy_pass \\\$stream_tls_target;" > 
/dev/null; then
+    echo "failed: a pure passthrough listen should not pay for the mixed-mode 
hop"
+    exit 1
+fi
+if grep -E "listen unix:.*stls-" conf/nginx.conf > /dev/null; then
+    echo "failed: internal sockets should only exist for mixed listens"
+    exit 1
+fi
+echo "passed: passthrough grouping leaves the other listens alone"
+
+# === A passthrough listen still honours proxy_protocol_to_upstream ===
+echo '
+apisix:
+  proxy_mode: "http&stream"
+  stream_proxy:
+    tcp:
+      - addr: 9102
+        tls_passthrough: true
+        proxy_protocol_to_upstream: true
+' > conf/config.yaml
+make init
+
+if ! block_with_listen 9102 | grep -E "ssl_preread on;" > /dev/null; then
+    echo "failed: passthrough + proxy_protocol_to_upstream should still 
preread"
+    exit 1
+fi
+if ! block_with_listen 9102 | grep -E "^\s*proxy_protocol on;" > /dev/null; 
then
+    echo "failed: passthrough + proxy_protocol_to_upstream should still send 
PROXY"
+    exit 1
+fi
+echo "passed: passthrough combines with proxy_protocol_to_upstream"
+
+# === proxy_protocol_to_upstream is refused on a mixed listen ===
+# The mixed block reaches its internal servers over a unix socket, and nginx
+# builds the upstream PROXY header from that socket, producing an invalid
+# destination like `unix:/... 0`.
+echo '
+apisix:
+  proxy_mode: "http&stream"
+  stream_proxy:
+    tcp:
+      - addr: 9103
+        tls: true
+        tls_passthrough: true
+        proxy_protocol_to_upstream: true
+' > conf/config.yaml
+
+out=$(make init 2>&1 || true)
+if ! echo "$out" | grep -q "can not combine proxy_protocol_to_upstream"; then
+    echo "failed: proxy_protocol_to_upstream on a mixed listen should be 
rejected"
+    exit 1
+fi
+
+# The same flag set globally must be refused too, not silently applied.
+echo '
+apisix:
+  proxy_mode: "http&stream"
+  proxy_protocol:
+    enable_tcp_pp_to_upstream: true
+  stream_proxy:
+    tcp:
+      - addr: 9103
+        tls: true
+        tls_passthrough: true
+' > conf/config.yaml
+
+out=$(make init 2>&1 || true)
+if ! echo "$out" | grep -q "can not combine proxy_protocol_to_upstream"; then
+    echo "failed: the global proxy protocol default should be refused on a 
mixed listen too"
+    exit 1
+fi
+echo "passed: proxy_protocol_to_upstream is refused on a mixed listen"
+
+# === Mixed mode end to end: one port, the route decides ===
+# 9101 terminates its own TLS and is reached untouched; 9102 is plaintext and 
is
+# reached only after the gateway terminates. Same port, same handshake target.
+# Both routes are pinned to server_port 9100: the internal servers see a unix
+# socket, so these only match if the router reads the address the PROXY header
+# carried rather than the socket's own.
+echo '
+apisix:
+  proxy_mode: "http&stream"
+  stream_proxy:
+    tcp:
+      - addr: 9100
+        tls: true
+        tls_passthrough: true
+nginx_config:
+  stream_configuration_snippet: |
+    server {
+        listen 9101 ssl;
+        ssl_certificate     ../t/certs/mtls_server.crt;
+        ssl_certificate_key ../t/certs/mtls_server.key;
+        return "OK FROM PASSTHROUGH BACKEND";
+    }
+    server {
+        listen 9102;
+        return "OK FROM TERMINATED BACKEND";
+    }
+' > conf/config.yaml
+
+make run
+wait_for_tcp 127.0.0.1 9180
+wait_for_tcp 127.0.0.1 9100
+
+admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 
's/"//g')
+curl -k -i http://127.0.0.1:9180/apisix/admin/ssls/1 \
+    -H "X-API-KEY: $admin_key" -X PUT -d '
+{
+    "cert": "'"$(cat t/certs/apisix.crt)"'",
+    "key": "'"$(cat t/certs/apisix.key)"'",
+    "snis": ["test.com"]
+}'
+
+curl -k -i http://127.0.0.1:9180/apisix/admin/stream_routes/1 \
+    -H "X-API-KEY: $admin_key" -X PUT -d \
+    
'{"sni":"admin.apisix.dev","server_port":9100,"tls_passthrough":true,"upstream":{"nodes":{"127.0.0.1:9101":1},"type":"roundrobin"}}'
+# The whitelist is the assertion that the client address survived the internal
+# unix-socket hop: without set_real_ip_from the plugin would see `unix:`.
+curl -k -i http://127.0.0.1:9180/apisix/admin/stream_routes/2 \
+    -H "X-API-KEY: $admin_key" -X PUT -d \
+    
'{"sni":"test.com","server_port":9100,"plugins":{"ip-restriction":{"whitelist":["127.0.0.1"]}},"upstream":{"nodes":{"127.0.0.1:9102":1},"type":"roundrobin"}}'
+
+if ! tls_probe_until 9100 admin.apisix.dev t/certs/mtls_ca.crt \
+        "OK FROM PASSTHROUGH BACKEND" \
+        "CN *= *admin.apisix.dev" \
+        "Verify return code: 0 (ok)"; then
+    echo "failed: a passthrough route on a mixed port should reach its backend 
untouched"
+    exit 1
+fi
+echo "passed: mixed port passes through the route that asked for it"
+
+if ! tls_probe_until 9100 test.com t/certs/apisix.crt \
+        "OK FROM TERMINATED BACKEND" \
+        "CN *= *test.com" \
+        "Verify return code: 0 (ok)"; then
+    echo "failed: a terminating route on the same port should be served by the 
gateway"
+    exit 1
+fi
+echo "passed: mixed port terminates the other route, with the client address 
intact"
+
+# And the client address is really being checked, not merely tolerated.
+curl -k -i http://127.0.0.1:9180/apisix/admin/stream_routes/2 \
+    -H "X-API-KEY: $admin_key" -X PUT -d \
+    
'{"sni":"test.com","plugins":{"ip-restriction":{"blacklist":["127.0.0.1"]}},"upstream":{"nodes":{"127.0.0.1:9102":1},"type":"roundrobin"}}'
+
+deadline=$(( $(date +%s) + 20 ))
+denied=0
+{ set +x; } 2>/dev/null
+while [ "$(date +%s)" -lt "$deadline" ]; do
+    if ! tls_probe 9100 test.com t/certs/apisix.crt | grep -q "OK FROM 
TERMINATED BACKEND"; then
+        denied=1
+        break
+    fi
+    sleep 0.3
+done
+set -x
+if [ "$denied" -ne 1 ]; then
+    echo "failed: blacklisting the real client address should deny the 
terminating route"
+    exit 1
+fi
+echo "passed: the internal hop reports the real client address, not the socket"
+
+# Drop what this test created before handing the etcd instance to the next one.
+# The blacklist route above matches sni test.com on port 9100, which is exactly
+# what t/cli/test_tls_over_tcp.sh then uses, so leaving it behind fails that 
test.
+for res in stream_routes/1 stream_routes/2 ssls/1; do
+    curl -k -s -o /dev/null http://127.0.0.1:9180/apisix/admin/$res \
+        -H "X-API-KEY: $admin_key" -X DELETE
+done
+
+make stop
+
+echo "All stream TLS passthrough tests passed."
diff --git a/t/stream-node/tls-passthrough.t b/t/stream-node/tls-passthrough.t
new file mode 100644
index 000000000..f0d138e86
--- /dev/null
+++ b/t/stream-node/tls-passthrough.t
@@ -0,0 +1,302 @@
+#
+# 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.
+#
+use t::APISIX 'no_plan';
+
+log_level('info');
+no_root_location();
+worker_connections(1024);
+
+# 1997 and 1998 terminate TLS themselves, so a client that completes a 
handshake
+# with either of them proves the bytes were forwarded rather than answered. The
+# two upstreams below are cross-mapped against the routes' own upstreams, so in
+# the mixed tests only the target the preread phase picked can explain the 
answer.
+my $stream_backends = <<'_EOC_';
+    upstream apisix_test_terminate   { server 127.0.0.1:1997; }
+    upstream apisix_test_passthrough { server 127.0.0.1:1998; }
+
+    server {
+        listen 1997 ssl;
+        # not cert/apisix.crt: the gateway holds no copy of this one
+        ssl_certificate     ../../certs/mtls_server.crt;
+        ssl_certificate_key ../../certs/mtls_server.key;
+        content_by_lua_block {
+            ngx.say("hello from backend A")
+        }
+    }
+
+    server {
+        listen 1998 ssl;
+        ssl_certificate     cert/apisix.crt;
+        ssl_certificate_key cert/apisix.key;
+        content_by_lua_block {
+            ngx.say("hello from backend B")
+        }
+    }
+_EOC_
+
+# A passthrough listen: no `ssl`, so it holds no certificate and could not
+# terminate anything even if it wanted to.
+my $passthrough_server = <<'_EOC_';
+    listen 2005;
+    ssl_preread on;
+
+    preread_by_lua_block {
+        ngx.sleep(0.1)
+        apisix.stream_preread_phase(true)
+    }
+
+    proxy_pass apisix_backend;
+_EOC_
+
+add_block_preprocessor(sub {
+    my ($block) = @_;
+
+    return unless defined $block->stream_tls_request;
+
+    if (!defined $block->extra_stream_config) {
+        $block->set_value("extra_stream_config", $stream_backends);
+    }
+
+    if (!defined $block->stream_server_config) {
+        $block->set_value("stream_server_config", $passthrough_server);
+    }
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: set two stream routes, matched by SNI
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin")
+
+            local code, body = t.test('/apisix/admin/stream_routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "sni": "admin.apisix.dev",
+                    "upstream": {
+                        "nodes": {"127.0.0.1:1997": 1},
+                        "type": "roundrobin"
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+                return
+            end
+
+            local code, body = t.test('/apisix/admin/stream_routes/2',
+                ngx.HTTP_PUT,
+                [[{
+                    "sni": "b.test.com",
+                    "upstream": {
+                        "nodes": {"127.0.0.1:1998": 1},
+                        "type": "roundrobin"
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+                return
+            end
+
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 2: the prereaded SNI selects the upstream, which terminates the 
handshake
+--- custom_trusted_cert: ../../certs/mtls_ca.crt
+--- stream_tls_request
+mmm
+--- stream_sni: admin.apisix.dev
+--- stream_tls_verify
+--- response_body
+hello from backend A
+--- error_log
+proxy request to 127.0.0.1:1997
+
+
+
+=== TEST 3: a second SNI on the same port picks the other upstream
+--- stream_tls_request
+mmm
+--- stream_sni: b.test.com
+--- response_body
+hello from backend B
+--- error_log
+proxy request to 127.0.0.1:1998
+
+
+
+=== TEST 4: a ClientHello with no SNI falls back instead of matching on ""
+--- yaml_config
+apisix:
+  node_listen: 1984
+  ssl:
+    fallback_sni: b.test.com
+--- stream_tls_request
+mmm
+--- response_body
+hello from backend B
+--- error_log
+proxy request to 127.0.0.1:1998
+
+
+
+=== TEST 5: set a passthrough route and a terminating route for the mixed 
listen
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin")
+
+            local code, body = t.test('/apisix/admin/stream_routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "sni": "admin.apisix.dev",
+                    "tls_passthrough": true,
+                    "upstream": {
+                        "nodes": {"127.0.0.1:1997": 1},
+                        "type": "roundrobin"
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+                return
+            end
+
+            local code, body = t.test('/apisix/admin/stream_routes/2',
+                ngx.HTTP_PUT,
+                [[{
+                    "sni": "b.test.com",
+                    "upstream": {
+                        "nodes": {"127.0.0.1:1998": 1},
+                        "type": "roundrobin"
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+                return
+            end
+
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 6: a route asking for passthrough goes to the passthrough target
+--- stream_server_config
+    listen 2005;
+    ssl_preread on;
+
+    set $stream_tls_target "";
+
+    preread_by_lua_block {
+        ngx.sleep(0.1)
+        apisix.stream_tls_route_phase("apisix_test_terminate",
+                                      "apisix_test_passthrough")
+    }
+
+    proxy_pass $stream_tls_target;
+--- stream_tls_request
+mmm
+--- stream_sni: admin.apisix.dev
+--- response_body
+hello from backend B
+--- error_log
+target: apisix_test_passthrough
+
+
+
+=== TEST 7: a route that did not ask for it goes to the terminating target
+--- stream_server_config
+    listen 2005;
+    ssl_preread on;
+
+    set $stream_tls_target "";
+
+    preread_by_lua_block {
+        ngx.sleep(0.1)
+        apisix.stream_tls_route_phase("apisix_test_terminate",
+                                      "apisix_test_passthrough")
+    }
+
+    proxy_pass $stream_tls_target;
+--- stream_tls_request
+mmm
+--- stream_sni: b.test.com
+--- response_body
+hello from backend A
+--- error_log
+target: apisix_test_terminate
+
+
+
+=== TEST 8: set a route with a tls upstream
+--- config
+    location /t {
+        content_by_lua_block {
+            local t = require("lib.test_admin")
+
+            local code, body = t.test('/apisix/admin/stream_routes/1',
+                ngx.HTTP_PUT,
+                [[{
+                    "sni": "admin.apisix.dev",
+                    "upstream": {
+                        "scheme": "tls",
+                        "nodes": {"127.0.0.1:1997": 1},
+                        "type": "roundrobin"
+                    }
+                }]]
+            )
+            if code >= 300 then
+                ngx.status = code
+                return
+            end
+
+            ngx.say(body)
+        }
+    }
+--- request
+GET /t
+--- response_body
+passed
+
+
+
+=== TEST 9: a tls upstream on a passthrough listen is refused
+--- stream_tls_request
+mmm
+--- stream_sni: admin.apisix.dev
+--- response_body_like eval
+qr/failed to (do SSL handshake|receive)/
+--- error_log
+upstream scheme `tls` can not be used on a tls_passthrough listen

Reply via email to