This is an automated email from the ASF dual-hosted git repository.
bzp2010 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 271e799696 fix(websocket): address review findings on the ws/wss proxy
path (#13977)
271e799696 is described below
commit 271e799696370769ef3ab876f89f21951414dc3b
Author: Zeping Bai <[email protected]>
AuthorDate: Tue Sep 22 09:22:35 2026 +0800
fix(websocket): address review findings on the ws/wss proxy path (#13977)
---
apisix/init.lua | 97 ++++++++++----
apisix/plugins/traffic-split.lua | 4 +-
apisix/upstream.lua | 8 ++
docs/en/latest/admin-api.md | 2 +-
docs/zh/latest/admin-api.md | 2 +-
t/lib/server.lua | 82 +++++++++---
t/node/websocket-proxy.spec.mts | 273 ++++++++++++++++++++++++++++++++++++---
t/node/websocket-proxy.t | 4 +
8 files changed, 412 insertions(+), 60 deletions(-)
diff --git a/apisix/init.lua b/apisix/init.lua
index 9440ea9e13..b25069c744 100644
--- a/apisix/init.lua
+++ b/apisix/init.lua
@@ -310,8 +310,7 @@ end
-- host per upstream.pass_host: pass = client's Host, rewrite = configured
--- upstream_host, node = picked node's host[:port]. Also used directly by the
--- websocket phase, which has no nginx variable to fall back on for "pass".
+-- upstream_host, node = picked node's host[:port].
local function compute_upstream_host(api_ctx, picked_server)
local pass_host = api_ctx.pass_host or "pass"
if pass_host == "rewrite" then
@@ -347,6 +346,16 @@ local function set_upstream_headers(api_ctx, picked_server)
end
+-- "example.com:443" -> "example.com", "[::1]:443" -> "::1": the name a TLS
+-- handshake sends as SNI and verifies the certificate against, which must not
+-- carry the port an upstream Host header may have. Same as nginx does for
+-- proxy_ssl_name.
+local function host_without_port(host)
+ local m = ngx_re_match(host, [=[^(?:\[([^\]]+)\]|([^:]+))]=], "jo")
+ return m and (m[1] or m[2]) or host
+end
+
+
-- hop-by-hop headers, plus handshake headers connect() already sets itself
-- (host/protocols/origin opts, or generated Sec-WebSocket-Key/-Version).
local ws_skip_forward_headers = {
@@ -1068,6 +1077,7 @@ function _M.websocket_content_phase()
ngx.ctx = fetch_ctx()
local api_ctx = ngx.ctx.api_ctx
local up_conf = api_ctx.upstream_conf
+ local up_scheme = api_ctx.upstream_scheme
-- a Route's own `timeout` overrides upstream.timeout, same as
-- set_balancer_opts() does for the plain proxy_pass path
local route = api_ctx.matched_route
@@ -1082,7 +1092,7 @@ function _M.websocket_content_phase()
-- resolve upstream.tls once, same as https/grpcs in apisix/upstream.lua
local ssl_verify, client_cert, client_priv_key
- if api_ctx.matched_upstream.scheme == "wss" and up_conf.tls then
+ if up_scheme == "wss" and up_conf.tls then
ssl_verify = up_conf.tls.verify
if up_conf.tls.client_cert or up_conf.tls.client_cert_id then
@@ -1126,7 +1136,7 @@ function _M.websocket_content_phase()
upstream_new_opts = {max_recv_len = upstream_max_len, max_send_len =
client_max_len}
end
- local ok, proxy, err = pcall(ws_proxy.new, {
+ local proxy_opts = {
aggregate_fragments = true,
recv_timeout = recv_timeout_ms,
client_new_opts = client_new_opts,
@@ -1162,19 +1172,23 @@ function _M.websocket_content_phase()
local new_frame = role_handler.get_frame()
return new_frame.payload, new_frame.code
end
- })
- if not ok then
- ngx.log(ngx.ERR, "failed to create proxy: ", proxy)
- return core.response.exit(500)
- end
- if not proxy then
- ngx.log(ngx.ERR, "failed to create proxy: ", err)
- return core.response.exit(500)
+ }
+
+ -- A client that got a non-101 answer is marked fatal and its socket
+ -- closed (see resty.websocket.client), so it cannot serve a retry against
+ -- another node: every connection attempt gets a fresh proxy.
+ local function new_proxy()
+ local ok, proxy, err = pcall(ws_proxy.new, proxy_opts)
+ if not ok then
+ return nil, proxy
+ end
+
+ return proxy, err
end
- -- proxy:connect() only sends the 101 response to the downstream client
- -- after it has successfully connected upstream, so it's safe to retry
- -- against another node here without having committed to the client yet.
+ -- the 101 response goes to the downstream client only in connect_client(),
+ -- after an upstream connection has succeeded, so it's safe to retry
against
+ -- another node here without having committed to the client yet.
local retries = up_conf.retries
if not retries or retries < 0 then
retries = #up_conf.nodes - 1
@@ -1196,8 +1210,8 @@ function _M.websocket_content_phase()
request_uri = api_ctx.var.uri .. (api_ctx.var.is_args or "") ..
(api_ctx.var.args or "")
end
+ local proxy, ok, connect_err
local server = api_ctx.picked_server
- local ok, connect_err
for attempt = 0, retries do
if attempt > 0 and retry_deadline and retry_deadline < ngx_now() then
ngx.log(ngx.ERR, "websocket proxy retry timeout, retry count: ",
attempt,
@@ -1205,15 +1219,32 @@ function _M.websocket_content_phase()
return core.response.exit(502)
end
+ local err
+ proxy, err = new_proxy()
+ if not proxy then
+ ngx.log(ngx.ERR, "failed to create proxy: ", err)
+ return core.response.exit(500)
+ end
+
if connect_timeout_ms then
proxy.client:set_timeout(connect_timeout_ms)
end
- local endpoint = str_format("%s://%s:%d%s",
api_ctx.matched_upstream.scheme,
- server.host, server.port, request_uri)
- ok, connect_err = proxy:connect(endpoint, {
- host = compute_upstream_host(api_ctx, server),
- server_name = server.domain,
+ -- what proxy_pass would send as Host and use as SNI: honors a host
+ -- set by plugins such as proxy-rewrite, and follows a retried node
+ -- for pass_host = node
+ set_upstream_host(api_ctx, server)
+ local host = api_ctx.var.upstream_host
+ if not host or host == "" then
+ host = api_ctx.var.http_host
+ end
+
+ -- the request URI is left out of anything logged: its query string
+ -- may carry credentials
+ local node_addr = str_format("%s://%s:%d", up_scheme, server.host,
server.port)
+ ok, connect_err = proxy:connect_upstream(node_addr .. request_uri, {
+ host = host,
+ server_name = host_without_port(host),
headers = ws_headers,
protocols = ws_protocols,
origin = ws_origin,
@@ -1225,7 +1256,7 @@ function _M.websocket_content_phase()
break
end
- ngx.log(ngx.ERR, "failed to connect to websocket upstream ", endpoint,
+ ngx.log(ngx.ERR, "failed to connect to websocket upstream ", node_addr,
": ", connect_err)
-- no balancer_by_lua* here, so report the outcome ourselves; a parsed
@@ -1261,7 +1292,27 @@ function _M.websocket_content_phase()
return core.response.exit(502)
end
- local done, err = proxy:execute()
+ -- The server side of the proxy answers the client's Sec-WebSocket-Protocol
+ -- offer by echoing it back as it stands, which would announce a
subprotocol
+ -- the upstream never selected. Leave it exactly what the upstream picked,
+ -- or nothing, before completing the client handshake.
+ local resp_headers = proxy.client:get_resp_headers()
+ local selected = resp_headers and resp_headers.sec_websocket_protocol
+ if type(selected) == "table" then
+ selected = selected[1]
+ end
+ core.request.set_header(api_ctx, "Sec-WebSocket-Protocol", selected)
+
+ local done, err = proxy:connect_client()
+ if not done then
+ ngx.log(ngx.ERR, "failed to complete the client websocket handshake:
", err)
+ return core.response.exit(400)
+ end
+
+ -- there is no header filter phase on this path to do this on the 101
+ api_ctx.var.request_type = "websocket"
+
+ done, err = proxy:execute()
if not done then
ngx.log(ngx.ERR, "failed proxying: ", err)
return core.response.exit(502)
diff --git a/apisix/plugins/traffic-split.lua b/apisix/plugins/traffic-split.lua
index 35243f502c..f01f719c78 100644
--- a/apisix/plugins/traffic-split.lua
+++ b/apisix/plugins/traffic-split.lua
@@ -201,7 +201,9 @@ local function set_upstream(upstream_info, ctx)
end
core.log.info("upstream_key: ", upstream_key)
upstream.set(ctx, upstream_key, ctx.conf_version, up_conf)
- if upstream_info.scheme == "https" then
+ -- the schemes handle_upstream() dispatches on ctx.upstream_scheme for
+ local scheme = upstream_info.scheme
+ if scheme == "https" or scheme == "ws" or scheme == "wss" then
upstream.set_scheme(ctx, up_conf)
end
return
diff --git a/apisix/upstream.lua b/apisix/upstream.lua
index 9f061d8543..76865fc064 100644
--- a/apisix/upstream.lua
+++ b/apisix/upstream.lua
@@ -673,6 +673,14 @@ local function check_upstream_conf(in_dp, conf)
then
return false, "`upstream_host` can't be empty when `pass_host` is
`rewrite`"
end
+
+ -- the ws/wss client connects through a plain cosocket, which can only
+ -- trust the global lua_ssl_trusted_certificate, not a per-upstream
store
+ if (conf.scheme == "ws" or conf.scheme == "wss")
+ and conf.tls and conf.tls.ca_certs
+ then
+ return false, "`tls.ca_certs` is not supported by the `ws`/`wss`
scheme"
+ end
end
if conf.tls and conf.tls.client_cert then
diff --git a/docs/en/latest/admin-api.md b/docs/en/latest/admin-api.md
index 7b0ac85340..f3e48ca6d6 100644
--- a/docs/en/latest/admin-api.md
+++ b/docs/en/latest/admin-api.md
@@ -1020,7 +1020,7 @@ In addition to the equalization algorithm selections,
Upstream also supports pas
| tls.client_key | False, can't be used with `tls.client_cert_id`
| HTTPS certificate private key | Sets the client private key
while connecting to a TLS Upstream.
[...]
| tls.client_cert_id | False, can't be used with `tls.client_cert`
and `tls.client_key` | SSL | Set the referenced
[SSL](#ssl) id.
[...]
| tls.verify | False
| Boolean | Enables or disables
verification of the Upstream certificate. Falls back to the nginx configuration
when unset. Also used by the `kafka` scheme.
[...]
-| tls.ca_certs | False
| Array of HTTPS certificates | CA certificates used to
verify the Upstream certificate, replacing the ones loaded from
`ssl_trusted_certificate`.
[...]
+| tls.ca_certs | False
| Array of HTTPS certificates | CA certificates used to
verify the Upstream certificate, replacing the ones loaded from
`ssl_trusted_certificate`. Not supported when `scheme` is `ws` or `wss`, which
only trust `ssl_trusted_certificate`.
[...]
| keepalive_pool.size | False
| Auxiliary | Sets `keepalive` directive
dynamically.
[...]
| keepalive_pool.idle_timeout | False
| Auxiliary | Sets `keepalive_timeout`
directive dynamically.
[...]
| keepalive_pool.requests | False
| Auxiliary | Sets `keepalive_requests`
directive dynamically.
[...]
diff --git a/docs/zh/latest/admin-api.md b/docs/zh/latest/admin-api.md
index b08318c76b..b8f3bd82b7 100644
--- a/docs/zh/latest/admin-api.md
+++ b/docs/zh/latest/admin-api.md
@@ -1028,7 +1028,7 @@ APISIX 的 Upstream 除了基本的负载均衡算法选择外,还支持对上
| tls.client_key | 否,不能和 `tls.client_cert_id` 一起使用 |
https 证书私钥 | 设置跟上游通信时的客户端私钥,详细信息请参考下文。
| |
| tls.client_cert_id | 否,不能和 `tls.client_cert`、`tls.client_key` 一起使用 | SSL
| 设置引用的 SSL id,详见 [SSL](#ssl)。
| |
| tls.verify | 否 | Boolean
| 开启或关闭上游证书校验,不设置时沿用 nginx 的配置,详细信息请参考下文。Kafka 上游同样使用该字段。
| |
-| tls.ca_certs | 否 | https
证书数组 | 用于校验上游证书的 CA 证书,设置后将取代 `ssl_trusted_certificate` 中加载的证书,详细信息请参考下文。
| |
+| tls.ca_certs | 否 | https
证书数组 | 用于校验上游证书的 CA 证书,设置后将取代 `ssl_trusted_certificate`
中加载的证书,详细信息请参考下文。`scheme` 为 `ws` 或 `wss` 时不支持该字段,只会信任
`ssl_trusted_certificate`。
| |
|keepalive_pool.size | 否 | 辅助 |
动态设置 `keepalive` 指令,详细信息请参考下文。 |
|keepalive_pool.idle_timeout | 否
| 辅助 | 动态设置 `keepalive_timeout` 指令,详细信息请参考下文。 |
|keepalive_pool.requests | 否 | 辅助
| 动态设置 `keepalive_requests` 指令,详细信息请参考下文。 |
diff --git a/t/lib/server.lua b/t/lib/server.lua
index ab0782d7d1..b2f057dc4f 100644
--- a/t/lib/server.lua
+++ b/t/lib/server.lua
@@ -372,6 +372,26 @@ function _M.wolf_rbac_custom_headers()
end
+-- send_close/send_pong return bytes, err; log a failure instead of silently
+-- dropping it, so a broken close/pong shows up in the fixture's error log.
+local function ws_send_close(wb, code, msg)
+ local bytes, err = wb:send_close(code, msg)
+ if not bytes then
+ ngx.log(ngx.ERR, "failed to send close frame: ", err)
+ end
+ return bytes, err
+end
+
+
+local function ws_send_pong(wb, data)
+ local bytes, err = wb:send_pong(data)
+ if not bytes then
+ ngx.log(ngx.ERR, "failed to send pong frame: ", err)
+ end
+ return bytes, err
+end
+
+
function _M.websocket_handshake()
local websocket = require "resty.websocket.server"
local wb, err = websocket:new()
@@ -412,10 +432,10 @@ function _M.websocket_echo()
end
if typ == "close" then
- wb:send_close(1000, "")
+ ws_send_close(wb, 1000, "")
return
elseif typ == "ping" then
- wb:send_pong(data)
+ ws_send_pong(wb, data)
elseif typ == "text" or typ == "binary" then
local send = typ == "text" and wb.send_text or wb.send_binary
local bytes, send_err = send(wb, data)
@@ -430,6 +450,35 @@ function _M.websocket_echo()
end
+-- Like websocket_echo, but the node listening on 1981 refuses the handshake
+-- with a plain 503 instead: that node stays reachable at the TCP level, so an
+-- active tcp health check never marks it unhealthy on its own, while a
+-- websocket client sees a non-101 response and has to retry another node.
+function _M.websocket_echo_or_reject()
+ if ngx.var.server_port == "1981" then
+ return ngx.exit(503)
+ end
+
+ return _M.websocket_echo()
+end
+
+
+-- Like websocket_echo, but answers the handshake with the one subprotocol
+-- named by ?select=<name>, or with none at all for ?select=none (or no
+-- select), regardless of what the client offered. Falls into the same echo
+-- loop afterwards.
+function _M.websocket_subprotocol()
+ local select = ngx.var.arg_select
+ if select and select ~= "" and select ~= "none" then
+ ngx.req.set_header("Sec-WebSocket-Protocol", select)
+ else
+ ngx.req.clear_header("Sec-WebSocket-Protocol")
+ end
+
+ return _M.websocket_echo()
+end
+
+
-- Like websocket_echo, but with a raised max_payload_len (and, through it,
-- max_recv_len/max_send_len) so this fixture itself is never the bottleneck
-- for a >64K single-frame test: whatever the test observes then comes from
@@ -453,10 +502,10 @@ function _M.websocket_echo_large()
end
if typ == "close" then
- wb:send_close(1000, "")
+ ws_send_close(wb, 1000, "")
return
elseif typ == "ping" then
- wb:send_pong(data)
+ ws_send_pong(wb, data)
elseif typ == "text" or typ == "binary" then
local send = typ == "text" and wb.send_text or wb.send_binary
local bytes, send_err = send(wb, data)
@@ -494,10 +543,10 @@ function _M.websocket_ack_large()
end
if typ == "close" then
- wb:send_close(1000, "")
+ ws_send_close(wb, 1000, "")
return
elseif typ == "ping" then
- wb:send_pong(data)
+ ws_send_pong(wb, data)
elseif typ == "text" or typ == "binary" then
local bytes, send_err = wb:send_text("received:" .. #data)
if not bytes then
@@ -540,10 +589,10 @@ function _M.websocket_send_large()
end
if typ == "close" then
- wb:send_close(1000, "")
+ ws_send_close(wb, 1000, "")
return
elseif typ == "ping" then
- wb:send_pong(data)
+ ws_send_pong(wb, data)
elseif typ == "text" or typ == "binary" then
local send = typ == "text" and wb.send_text or wb.send_binary
local ok, echo_err = send(wb, data)
@@ -587,10 +636,10 @@ function _M.websocket_echo_uri()
end
if typ == "close" then
- wb:send_close(1000, "")
+ ws_send_close(wb, 1000, "")
return
elseif typ == "ping" then
- wb:send_pong(data)
+ ws_send_pong(wb, data)
elseif typ == "text" or typ == "binary" then
local send = typ == "text" and wb.send_text or wb.send_binary
local ok, echo_err = send(wb, data)
@@ -606,8 +655,8 @@ end
-- Like websocket_echo, but the first thing it sends back is a text frame
--- carrying the X-Real-IP/X-Forwarded-For it actually received as JSON, so a
--- test can confirm what a fronting proxy set them to. Falls into the same
+-- carrying the Host/X-Real-IP/X-Forwarded-For it actually received as JSON, so
+-- a test can confirm what a fronting proxy set them to. Falls into the same
-- echo loop afterwards.
function _M.websocket_echo_headers()
local websocket = require "resty.websocket.server"
@@ -619,6 +668,7 @@ function _M.websocket_echo_headers()
local headers = ngx.req.get_headers()
local bytes, send_err = wb:send_text(json_encode({
+ host = headers["Host"],
x_real_ip = headers["X-Real-IP"],
x_forwarded_for = headers["X-Forwarded-For"],
}))
@@ -638,10 +688,10 @@ function _M.websocket_echo_headers()
end
if typ == "close" then
- wb:send_close(1000, "")
+ ws_send_close(wb, 1000, "")
return
elseif typ == "ping" then
- wb:send_pong(data)
+ ws_send_pong(wb, data)
elseif typ == "text" or typ == "binary" then
local send = typ == "text" and wb.send_text or wb.send_binary
local ok, echo_err = send(wb, data)
@@ -691,7 +741,7 @@ function _M.websocket_fragment()
return
end
if typ == "close" then
- wb:send_close(1000, "")
+ ws_send_close(wb, 1000, "")
return
end
::continue::
@@ -709,7 +759,7 @@ function _M.websocket_close_upstream_initiated()
return ngx.exit(400)
end
- wb:send_close(1000, "bye")
+ ws_send_close(wb, 1000, "bye")
end
diff --git a/t/node/websocket-proxy.spec.mts b/t/node/websocket-proxy.spec.mts
index a1043b447e..d5b3735662 100644
--- a/t/node/websocket-proxy.spec.mts
+++ b/t/node/websocket-proxy.spec.mts
@@ -16,6 +16,8 @@
*/
import { describe, expect, it, jest } from '@jest/globals';
import axios from 'axios';
+import { readFileSync } from 'node:fs';
+import { type IncomingHttpHeaders, request } from 'node:http';
import WS from 'ws';
import { request as requestAdminAPI } from '../ts/admin_api';
@@ -53,7 +55,7 @@ const createRoute = async (
upstream,
plugins,
});
- expect(res.status).toBe(res.status < 300 ? res.status : 200);
+ expect(res.status).toBeLessThan(300);
// give etcd -> apisix config sync a moment to land before the first request
await wait(300);
return id;
@@ -67,15 +69,76 @@ const createRoute = async (
// that instant. PUTting the same fixed route id instead is a plain
// overwrite, so there's no delete in flight to race with.
const ECHO_ROUTE_ID = 'ws-proxy-echo';
-const putEchoRoute = async (upstream: object, plugins?: object) => {
- const res = await requestAdminAPI(`/apisix/admin/routes/${ECHO_ROUTE_ID}`,
'PUT', {
- uri: '/websocket_echo',
+const putRoute = async (id: string, uri: string, upstream: object, plugins?:
object) => {
+ const res = await requestAdminAPI(`/apisix/admin/routes/${id}`, 'PUT', {
+ uri,
upstream,
plugins,
});
- expect(res.status).toBe(res.status < 300 ? res.status : 200);
+ expect(res.status).toBeLessThan(300);
await wait(300);
};
+const putEchoRoute = (upstream: object, plugins?: object) =>
+ putRoute(ECHO_ROUTE_ID, '/websocket_echo', upstream, plugins);
+// same idea for the fixture that reports the handshake headers it received
+const putHeadersRoute = (upstream: object, plugins?: object) =>
+ putRoute('ws-proxy-headers', '/websocket_echo_headers', upstream, plugins);
+// and for the fixture whose 127.0.0.1:1981 node refuses the handshake with a
503
+const REJECT_ROUTE_ID = 'ws-proxy-reject';
+const putRejectRoute = (upstream: object, plugins?: object) =>
+ putRoute(REJECT_ROUTE_ID, '/websocket_echo_or_reject', upstream, plugins);
+
+// Opens a websocket connection and resolves with the first frame received,
+// without sending anything: for fixtures that speak first.
+const receiveFirst = (url: string, protocols?: string[]) =>
+ new Promise<{ data: string; protocol: string }>((resolve, reject) => {
+ const ws = new WS(url, protocols);
+ ws.on('message', (data) => {
+ resolve({ data: data.toString(), protocol: ws.protocol });
+ ws.close();
+ });
+ ws.on('error', reject);
+ });
+
+// Resolves with the headers of the 101 answer to a handshake that offers the
+// given subprotocols. A raw request rather than a WebSocket client, since the
+// clients reject a server that selects none of the subprotocols they offered,
+// which is exactly the answer some of these cases are about.
+const handshakeHeaders = (path: string, protocols: string[]) =>
+ new Promise<IncomingHttpHeaders>((resolve, reject) => {
+ const req = request({
+ host: '127.0.0.1',
+ port: 1984,
+ path,
+ headers: {
+ Connection: 'Upgrade',
+ Upgrade: 'websocket',
+ 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
+ 'Sec-WebSocket-Version': '13',
+ 'Sec-WebSocket-Protocol': protocols.join(', '),
+ },
+ });
+ req.on('upgrade', (res, socket) => {
+ socket.destroy();
+ resolve(res.headers);
+ });
+ req.on('response', (res) => reject(new Error(`unexpected status
${res.statusCode}`)));
+ req.on('error', reject);
+ req.end();
+ });
+
+// A plain http upgrade request, for asserting on the status the proxy itself
+// answers with when it cannot complete the handshake.
+const rawUpgrade = (path: string) =>
+ axios.get(`http://127.0.0.1:1984${path}`, {
+ headers: {
+ Connection: 'Upgrade',
+ Upgrade: 'websocket',
+ 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
+ 'Sec-WebSocket-Version': '13',
+ },
+ validateStatus: () => true,
+ });
// Opens a websocket connection, sends one text frame, resolves with the
// first frame received in reply (or rejects on error/close-before-reply).
@@ -124,6 +187,14 @@ describe('websocket-proxy (ws/wss upstream scheme)', () =>
{
// example-plugin's ws_client_frame/ws_upstream_frame hooks append
// "-client"/"-upstream" to every text frame they see, in-flight.
'example-plugin': { i: 1 },
+ // the log phase runs once the session is over: report the request
+ // type there, which only a websocket session should have set
+ 'serverless-post-function': {
+ phase: 'log',
+ functions: [
+ 'return function(conf, ctx) ngx.log(ngx.WARN, "ws request_type:
", ctx.var.request_type) end',
+ ],
+ },
},
);
@@ -349,31 +420,43 @@ describe('websocket-proxy (ws/wss upstream scheme)', ()
=> {
});
describe('passive health check', () => {
- it('marks a node unhealthy after enough failed connection attempts', async
() => {
- await putEchoRoute({
+ it('marks a node unhealthy after it answers the handshake with a failing
status', async () => {
+ // 127.0.0.1:1981 accepts TCP connections but answers every handshake
with
+ // a 503 (see websocket_echo_or_reject), so the active tcp check below
can
+ // never flag it on its own: only the passive http status report the
proxy
+ // makes for the non-101 response can move it to unhealthy.
+ await putRejectRoute({
type: 'roundrobin',
scheme: 'ws',
retries: 1,
- nodes: { [DEAD_NODE]: 1, [ECHO_NODE]: 1 },
+ nodes: { '127.0.0.1:1981': 1, [ECHO_NODE]: 1 },
checks: {
- active: { type: 'tcp', http_path: '/', timeout: 1, healthy: {
interval: 1 } },
- passive: { unhealthy: { tcp_failures: 1 } },
+ // probes only once at startup and then stay out of the way, so they
can
+ // neither flag the node unhealthy nor flip it back to healthy again
+ active: {
+ type: 'tcp',
+ host: '127.0.0.1',
+ timeout: 1,
+ healthy: { interval: 3600 },
+ unhealthy: { interval: 3600 },
+ },
+ passive: { unhealthy: { http_statuses: [503], http_failures: 1 } },
},
});
- // one connect attempt is enough to report a tcp failure for DEAD_NODE
- await sendAndReceive('/websocket_echo', 'hello');
-
let unhealthyFound = false;
for (let i = 0; i < 10 && !unhealthyFound; i++) {
+ // each request may or may not pick the 503 node first; the retry makes
+ // it succeed either way, and a pick of that node reports the failure
+ expect(await sendAndReceive('/websocket_echo_or_reject',
'hello')).toBe('hello');
await wait(500);
- const res = await
requestAdminAPI(`/v1/healthcheck/routes/${ECHO_ROUTE_ID}`);
- const { nodes } = res.data as { nodes: { ip: string; port: number;
status: string }[] };
- unhealthyFound = nodes.some((n) => n.port === 1 && n.status !==
'healthy');
+ const res = await
requestAdminAPI(`/v1/healthcheck/routes/${REJECT_ROUTE_ID}`);
+ const { nodes } = res.data as { nodes: { port: number; status: string
}[] };
+ unhealthyFound = nodes.some((n) => n.port === 1981 && n.status !==
'healthy');
}
expect(unhealthyFound).toBe(true);
- }, 15000);
+ }, 30000);
});
describe('upstream URI forwarding', () => {
@@ -426,7 +509,7 @@ describe('websocket-proxy (ws/wss upstream scheme)', () => {
describe('client address headers', () => {
it('overrides X-Real-IP and appends this hop to X-Forwarded-For, not what
the client sent', async () => {
- await createRoute('/websocket_echo_headers', {
+ await putHeadersRoute({
type: 'roundrobin',
scheme: 'ws',
nodes: { [ECHO_NODE]: 1 },
@@ -451,6 +534,160 @@ describe('websocket-proxy (ws/wss upstream scheme)', ()
=> {
});
});
+ describe('upstream Host header', () => {
+ it('honors the host set by proxy-rewrite on the upstream handshake', async
() => {
+ await putHeadersRoute(
+ { type: 'roundrobin', scheme: 'ws', nodes: { [ECHO_NODE]: 1 } },
+ { 'proxy-rewrite': { host: 'rewritten.example.com' } },
+ );
+
+ const { data } = await
receiveFirst('ws://127.0.0.1:1984/websocket_echo_headers');
+ expect(JSON.parse(data).host).toBe('rewritten.example.com');
+ });
+
+ it("sends the retried node's own host with pass_host: node", async () => {
+ await putHeadersRoute({
+ type: 'roundrobin',
+ scheme: 'ws',
+ pass_host: 'node',
+ retries: 1,
+ nodes: { [DEAD_NODE]: 100, [ECHO_NODE]: 1 },
+ });
+
+ const { data } = await
receiveFirst('ws://127.0.0.1:1984/websocket_echo_headers');
+ expect(JSON.parse(data).host).toBe(ECHO_NODE);
+ });
+ });
+
+ describe('wss upstream', () => {
+ // the fake server's TLS listener; its certificate is issued for test.com
+ const TLS_NODE = '127.0.0.1:1983';
+
+ it('proxies over TLS with certificate verification off', async () => {
+ await putHeadersRoute({
+ type: 'roundrobin',
+ scheme: 'wss',
+ tls: { verify: false },
+ nodes: { [TLS_NODE]: 1 },
+ });
+
+ const { data } = await
receiveFirst('ws://127.0.0.1:1984/websocket_echo_headers');
+ expect(JSON.parse(data).host).toBe('127.0.0.1:1984');
+ });
+
+ it('verifies the certificate against the upstream host, port excluded',
async () => {
+ await putHeadersRoute({
+ type: 'roundrobin',
+ scheme: 'wss',
+ pass_host: 'rewrite',
+ upstream_host: 'test.com:1983',
+ tls: { verify: true },
+ nodes: { [TLS_NODE]: 1 },
+ });
+
+ const { data } = await
receiveFirst('ws://127.0.0.1:1984/websocket_echo_headers');
+ expect(JSON.parse(data).host).toBe('test.com:1983');
+ });
+
+ it('refuses an upstream whose certificate does not match the host', async
() => {
+ await putHeadersRoute({
+ type: 'roundrobin',
+ scheme: 'wss',
+ tls: { verify: true },
+ nodes: { [TLS_NODE]: 1 },
+ });
+
+ const res = await rawUpgrade('/websocket_echo_headers');
+ expect(res.status).toBe(502);
+ });
+
+ it('rejects tls.ca_certs, which the ws/wss client cannot apply', async ()
=> {
+ const cert = readFileSync(new URL('../certs/apisix.crt',
import.meta.url), 'utf8');
+ const res = await requestAdminAPI(
+ '/apisix/admin/upstreams/ws-proxy-ca-certs',
+ 'PUT',
+ {
+ type: 'roundrobin',
+ scheme: 'wss',
+ tls: { verify: true, ca_certs: [cert] },
+ nodes: { [TLS_NODE]: 1 },
+ },
+ undefined,
+ { validateStatus: () => true },
+ );
+ expect(res.status).toBe(400);
+ });
+ });
+
+ describe('subprotocol negotiation', () => {
+ it('answers the client with the subprotocol the upstream selected', async
() => {
+ await createRoute('/websocket_subprotocol', {
+ type: 'roundrobin',
+ scheme: 'ws',
+ nodes: { [ECHO_NODE]: 1 },
+ });
+
+ const headers = await
handshakeHeaders('/websocket_subprotocol?select=chat', [
+ 'other',
+ 'chat',
+ ]);
+ expect(headers['sec-websocket-protocol']).toBe('chat');
+ });
+
+ it('answers with no subprotocol when the upstream selected none', async ()
=> {
+ // echoing the client's whole offer back instead would announce
+ // subprotocols the upstream never agreed to
+ const headers = await
handshakeHeaders('/websocket_subprotocol?select=none', [
+ 'other',
+ 'chat',
+ ]);
+ expect(headers['sec-websocket-protocol']).toBeUndefined();
+ });
+ });
+
+ describe('traffic-split', () => {
+ it('proxies frames through a ws upstream chosen by traffic-split', async
() => {
+ // the route's own upstream is plain http: only the traffic-split pick
is ws
+ await putEchoRoute(
+ { type: 'roundrobin', scheme: 'http', nodes: { [ECHO_NODE]: 1 } },
+ {
+ 'traffic-split': {
+ rules: [
+ {
+ weighted_upstreams: [
+ {
+ upstream: {
+ type: 'roundrobin',
+ scheme: 'ws',
+ nodes: { [ECHO_NODE]: 1 },
+ },
+ weight: 1,
+ },
+ ],
+ },
+ ],
+ },
+ },
+ );
+
+ expect(await sendAndReceive('/websocket_echo', 'hello')).toBe('hello');
+ });
+ });
+
+ describe('upstream retry after a non-101 handshake', () => {
+ it('retries the next node and completes the session on it', async () => {
+ // 127.0.0.1:1981 answers the handshake with a 503
(websocket_echo_or_reject)
+ await putRejectRoute({
+ type: 'roundrobin',
+ scheme: 'ws',
+ retries: 1,
+ nodes: { '127.0.0.1:1981': 100, [ECHO_NODE]: 1 },
+ });
+
+ expect(await sendAndReceive('/websocket_echo_or_reject',
'hello')).toBe('hello');
+ });
+ });
+
describe('frame size (websocket-proxy plugin)', () => {
it('closes the connection on a single frame over the 65535-byte default',
async () => {
await putEchoRoute({
diff --git a/t/node/websocket-proxy.t b/t/node/websocket-proxy.t
index 73b8fe9251..dc62eac3e0 100644
--- a/t/node/websocket-proxy.t
+++ b/t/node/websocket-proxy.t
@@ -29,6 +29,10 @@ __DATA__
--- max_size: 2048000
--- exec
cd t && pnpm test node/websocket-proxy.spec.mts 2>&1
+--- error_log
+plugin ws_handshake phase
+plugin ws_close phase
+ws request_type: websocket
--- no_error_log
failed to execute the script with status
--- response_body eval