From 40989f1b859174dc613a41a2b3022b0287f0b94c Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Mon, 21 Sep 2026 13:47:05 +0545 Subject: [PATCH 1/5] chore: release 3.19.0 --- .requirements | 2 +- CHANGELOG.md | 61 ++++++++++++++++++++++++++++++++++++++ apisix/core/version.lua | 2 +- ci/check_changelog_prs.ts | 4 +++ docs/en/latest/config.json | 2 +- docs/zh/latest/config.json | 2 +- 6 files changed, 69 insertions(+), 4 deletions(-) diff --git a/.requirements b/.requirements index 1dbfc0f7ea09..f633665c68fd 100644 --- a/.requirements +++ b/.requirements @@ -18,4 +18,4 @@ APISIX_PACKAGE_NAME=apisix APISIX_RUNTIME=1.3.18 -APISIX_DASHBOARD_COMMIT=045e3142867e3b7d5d1b8ec40bf8f66a7ce24a64 +APISIX_DASHBOARD_COMMIT=fa2fd0f60f8afffb096476333b9ba63b4c518fa3 diff --git a/CHANGELOG.md b/CHANGELOG.md index e2ab2afb1f9d..25585c1f4a80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ title: Changelog ## Table of Contents +- [3.19.0](#3190) - [3.18.0](#3180) - [3.17.0](#3170) - [3.16.0](#3160) @@ -86,6 +87,66 @@ title: Changelog - [0.7.0](#070) - [0.6.0](#060) +## 3.19.0 + +**The changes marked with :warning: are not backward compatible.** + +### Change + +- :warning: feat(upstream): verify the upstream certificate against configurable CAs. `upstream.tls.verify` was only read by the `kafka` scheme and is now honoured for `https` and `grpcs` as well, so an upstream that already carried `verify: true` starts rejecting a certificate it cannot validate; `tls.ca_certs` picks the trust anchors per upstream [#13863](https://github.com/apache/apisix/pull/13863) +- :warning: fix(openid-connect): validate the introspection issuer. With an explicit `claim_validator.issuer.valid_issuers`, a successful remote introspection response must now carry a string `iss` matching one of them, or the request is rejected with 401; omit the allowlist to keep the previous behavior [#13916](https://github.com/apache/apisix/pull/13916) +- :warning: fix(batch-requests): bound aggregated response bodies. New `max_response_body_size` (1 MiB) and `max_response_body_size_total` (10 MiB) plugin metadata; a pipeline above either limit now returns 502 instead of the full aggregate [#13906](https://github.com/apache/apisix/pull/13906) +- :warning: fix(basic-auth): reject an empty consumer password. `password` requires `minLength: 1`, so the Admin API rejects an empty value and a consumer already stored with one fails closed with 401 [#13884](https://github.com/apache/apisix/pull/13884) +- :warning: fix(ai-proxy-multi): reject instances that share a name. `instance.name` is the instance identity across the balancer, the health checker, `ai-rate-limiting` and `semantic_opts.fallback`, so a route whose instances share a name is now rejected on write and dropped on reload [#13851](https://github.com/apache/apisix/pull/13851) +- :warning: fix(workflow): reject invalid case expressions and missing action conf. A `case` expression that was silently accepted and then matched every request is now a schema error, and `actions` is pinned to exactly one `[name, conf]` pair, so a rule carrying several actions (only the first ever ran) or an action without its conf is rejected on write and dropped on reload [#13862](https://github.com/apache/apisix/pull/13862) + +### Core + +- feat(stream): support TLS passthrough on the stream proxy, so a stream route can pick its upstream from the SNI in the prereaded ClientHello and still forward the session encrypted [#13912](https://github.com/apache/apisix/pull/13912) +- feat(stream): match a stream route by several SNIs through the new `snis` field, mutually exclusive with `sni` [#13911](https://github.com/apache/apisix/pull/13911) +- feat(websocket): add the `ws`/`wss` upstream scheme, which proxies frames through APISIX itself and exposes the `ws_handshake`, `ws_client_frame`, `ws_upstream_frame` and `ws_close` plugin phases plus the `core.websocket` API [#13939](https://github.com/apache/apisix/pull/13939) +- feat(upstream): slow start for newly observed upstream nodes via `warm_up_conf` [#13941](https://github.com/apache/apisix/pull/13941) +- feat: improve API-driven standalone update reliability: workers report a per-entity configuration digest, `PUT /apisix/admin/configs` accepts a `wait` parameter and answers 200 once every worker has loaded the configuration (202 otherwise), and the shdict format carries the digest outside the JSON [#13904](https://github.com/apache/apisix/pull/13904) +- feat(control-api): report the health checks a plugin owns, so `ai-proxy-multi` instance checkers show up in `/v1/healthcheck` [#13899](https://github.com/apache/apisix/pull/13899) +- chore: upgrade lua-resty-dns-client to 7.1.2, fixing `finalCacheOnly` so a CNAME chain no longer fails with `empty record received` when the answer carries an EDNS(0) OPT record or is not in chain order [#13875](https://github.com/apache/apisix/pull/13875) +- fix(etcd): watch from the revision the configuration was read at, so a write made while APISIX is starting is no longer lost [#13917](https://github.com/apache/apisix/pull/13917) +- fix(etcd): check etcd availability before starting the watcher, so config objects do not wait on a watcher that never connected [#13934](https://github.com/apache/apisix/pull/13934) +- fix(etcd): do not block writes when the deployment role cannot be read [#13885](https://github.com/apache/apisix/pull/13885) +- fix(standalone): stop aborting stream connections before the first config arrives [#13855](https://github.com/apache/apisix/pull/13855) +- fix(standalone): harden the declarative configuration paths: validate the shape of the request body instead of 500ing, log the parser error rather than the body (which can carry credentials and private keys), check a stream route's `superior_id` self reference during validation, and guard null deployment sections in the CLI [#13886](https://github.com/apache/apisix/pull/13886) +- fix(control): always report healthcheck nodes as a JSON array, so `nodes` and the top-level list of `/v1/healthcheck` are `[]` instead of `{}` when empty [#13891](https://github.com/apache/apisix/pull/13891) +- fix(plugin): align unavailable plugin handling: reject unknown plugin names before persistence, keep data-plane loading tolerant of them, and warn when one is skipped [#13928](https://github.com/apache/apisix/pull/13928) +- fix: preserve servlet upstream URI boundaries by encoding the original path before proxying when servlet-style normalization is enabled [#13914](https://github.com/apache/apisix/pull/13914) +- feat: label WebSocket sessions with `request_type=websocket`. A request answered with `101 Switching Protocols` is reported as `websocket` instead of `traditional_http` in `apisix_http_status`, `apisix_http_latency` and `apisix_bandwidth`, so a session can be kept out of latency queries [#13909](https://github.com/apache/apisix/pull/13909) +- fix: write `request_type=websocket` through `ctx.var`, so a plugin that resolved `$request_type` before the upgrade does not leave the cached value at `traditional_http` [#13915](https://github.com/apache/apisix/pull/13915) + +### Plugins + +- feat: add the `openapi-to-mcp` plugin, serving an HTTP API to MCP clients from its OpenAPI document over Streamable HTTP and HTTP+SSE [#13942](https://github.com/apache/apisix/pull/13942) +- feat(websocket): add the `websocket-proxy` plugin to customize proxy behaviors, starting with `client_max_payload_len` / `upstream_max_payload_len` for `ws`/`wss` upstreams [#13972](https://github.com/apache/apisix/pull/13972) +- feat(graphql-limit-count): rate limit by GraphQL query cost. New `complexity` and `node_quantifier` cost strategies with per-field weights stored as `graphql_cost_decorations` under a Service; `depth` stays the default [#13840](https://github.com/apache/apisix/pull/13840) +- feat: chaitin-waf response logging through `log_resp`, `resp_body_size` and `extra_ignored_content_types`, reported asynchronously after the response has been handed back to the client [#13763](https://github.com/apache/apisix/pull/13763) +- feat(ai-proxy-multi): let configured HTTP statuses trigger a fallback via `fallback_http_statuses` [#13852](https://github.com/apache/apisix/pull/13852) +- feat(saml-auth): add the lua-resty-saml 0.2.6 validation options `idp_issuers`, `sp_acs_url`, `sp_audiences`, `clock_skew`, `replay_dict` and `replay_ttl` [#13964](https://github.com/apache/apisix/pull/13964) +- fix(redis): send the TLS SNI and add `redis_server_name`, so a Redis behind a name-routed TLS front works with `redis_ssl: true`; the SNI is skipped for an IP literal host [#13938](https://github.com/apache/apisix/pull/13938) +- fix(ai-proxy): return 502 when a streaming upstream produces no output, instead of falling through to `balancer_by_lua` and answering nothing [#13870](https://github.com/apache/apisix/pull/13870) +- fix(ai-proxy): do not turn a streaming read error after partial output into a 5xx, and do not retry a request whose partial output already reached the client [#13876](https://github.com/apache/apisix/pull/13876) +- fix(ai-proxy): avoid aborting streams on empty flushes [#13947](https://github.com/apache/apisix/pull/13947) +- fix(ai-providers): encode the Vertex AI model path segment [#13872](https://github.com/apache/apisix/pull/13872) +- fix(ai-cache): key the passthrough protocol on the client method, path and query, so two upstream endpoints no longer collide on one cache entry [#13887](https://github.com/apache/apisix/pull/13887) +- fix(ai-aliyun-content-moderation): report final results without usage [#13922](https://github.com/apache/apisix/pull/13922) +- fix(feishu-auth, dingtalk-auth): bind the authorization code to the session that started the login. A random `state` is appended to the `redirect_uri` redirect and required back on the callback, so a code obtained elsewhere is no longer accepted on any session. Whatever serves `redirect_uri` has to pass `state` on to the identity provider; the header code path (`X-Feishu-Code` / `X-DingTalk-Code`) is unchanged [#13806](https://github.com/apache/apisix/pull/13806) +- fix(jwe-decrypt): accept JWE tokens that authenticate the protected header, as RFC 7516 requires, and reject an unsupported `alg` or `enc` [#13889](https://github.com/apache/apisix/pull/13889) +- fix(jwe-decrypt): reject malformed tokens with 400 instead of returning 500 [#13844](https://github.com/apache/apisix/pull/13844) +- fix(basic-auth): split credentials on the first colon only, so a password containing `:` is no longer truncated [#13836](https://github.com/apache/apisix/pull/13836) +- fix(data-mask): keep request header masking effective in the log phase, where `set_header()` silently did nothing on a 400 response [#13839](https://github.com/apache/apisix/pull/13839) +- fix(redirect): compare `X-Forwarded-Proto` case-insensitively, so a proxy forwarding `HTTPS` no longer triggers an `http_to_https` redirect loop [#13865](https://github.com/apache/apisix/pull/13865) +- fix(ua-restriction): deny the request when any User-Agent header matches the `deny_list` [#13869](https://github.com/apache/apisix/pull/13869) +- fix(traffic-label): cache the compiled match expressions outside the plugin config, so the route configuration stays JSON encodable [#13901](https://github.com/apache/apisix/pull/13901) +- fix(aws-lambda): request `/` for a path-less `function_uri` and log function error responses [#13908](https://github.com/apache/apisix/pull/13908) +- fix(ext-plugin-post-resp): set `upstream_addr` and `upstream_response_time` for loggers [#13940](https://github.com/apache/apisix/pull/13940) +- fix(openapi-to-mcp): apply schema defaults before validation, keep the resolved `base_url` and headers for the lifetime of an SSE session, and reject a document that is not an OpenAPI document [#13956](https://github.com/apache/apisix/pull/13956) + ## 3.18.0 **The changes marked with :warning: are not backward compatible.** diff --git a/apisix/core/version.lua b/apisix/core/version.lua index b02e89d44c29..2f6b772076a3 100644 --- a/apisix/core/version.lua +++ b/apisix/core/version.lua @@ -20,5 +20,5 @@ -- @module core.version return { - VERSION = "3.18.0" + VERSION = "3.19.0" } diff --git a/ci/check_changelog_prs.ts b/ci/check_changelog_prs.ts index b69b20d73d6c..b9fc4f6da610 100755 --- a/ci/check_changelog_prs.ts +++ b/ci/check_changelog_prs.ts @@ -69,6 +69,10 @@ const IGNORE_PRS = [ // "fix(ci)", "fix(dev-image)", "build:") dodges the docs/chore/test/ci type // filter but which do not belong in a user changelog. 13526, 13554, 13679, 13709, 13815, 13824, + // 3.19.0 + // CI-only changes whose "fix(ci)" subject prefix dodges the docs/chore/test/ci + // type filter but which do not belong in a user changelog. + 13921, 13923, ]; diff --git a/docs/en/latest/config.json b/docs/en/latest/config.json index 7397a7cf7a15..bf2424a500ac 100644 --- a/docs/en/latest/config.json +++ b/docs/en/latest/config.json @@ -1,5 +1,5 @@ { - "version": "3.18.0", + "version": "3.19.0", "sidebar": [ { "type": "category", diff --git a/docs/zh/latest/config.json b/docs/zh/latest/config.json index baf9fb0b1b7a..36da7ac936a0 100644 --- a/docs/zh/latest/config.json +++ b/docs/zh/latest/config.json @@ -1,5 +1,5 @@ { - "version": "3.18.0", + "version": "3.19.0", "sidebar": [ { "type": "category", From 51d3d6527adcd4abc9ebc2be6efae11b7cdc5518 Mon Sep 17 00:00:00 2001 From: Zeping Bai Date: Tue, 22 Sep 2026 09:22:35 +0800 Subject: [PATCH 2/5] 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 9440ea9e1313..b25069c7445b 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 35243f502c94..f01f719c7890 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 9f061d854349..76865fc06495 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 7b0ac8534089..f3e48ca6d61a 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 b08318c76b09..b8f3bd82b753 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 ab0782d7d15f..b2f057dc4f1c 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=, 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 a1043b447eff..d5b3735662bd 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((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 73b8fe9251c6..dc62eac3e027 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 From 3c066c03f153fa704a1e02218412ad2392920363 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Tue, 22 Sep 2026 12:25:42 +0545 Subject: [PATCH 3/5] chore: note #13977 on the 3.19.0 websocket entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25585c1f4a80..dcb8c876d5b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,7 +104,7 @@ title: Changelog - feat(stream): support TLS passthrough on the stream proxy, so a stream route can pick its upstream from the SNI in the prereaded ClientHello and still forward the session encrypted [#13912](https://github.com/apache/apisix/pull/13912) - feat(stream): match a stream route by several SNIs through the new `snis` field, mutually exclusive with `sni` [#13911](https://github.com/apache/apisix/pull/13911) -- feat(websocket): add the `ws`/`wss` upstream scheme, which proxies frames through APISIX itself and exposes the `ws_handshake`, `ws_client_frame`, `ws_upstream_frame` and `ws_close` plugin phases plus the `core.websocket` API [#13939](https://github.com/apache/apisix/pull/13939) +- feat(websocket): add the `ws`/`wss` upstream scheme, which proxies frames through APISIX itself and exposes the `ws_handshake`, `ws_client_frame`, `ws_upstream_frame` and `ws_close` plugin phases plus the `core.websocket` API [#13939](https://github.com/apache/apisix/pull/13939) [#13977](https://github.com/apache/apisix/pull/13977) - feat(upstream): slow start for newly observed upstream nodes via `warm_up_conf` [#13941](https://github.com/apache/apisix/pull/13941) - feat: improve API-driven standalone update reliability: workers report a per-entity configuration digest, `PUT /apisix/admin/configs` accepts a `wait` parameter and answers 200 once every worker has loaded the configuration (202 otherwise), and the shdict format carries the digest outside the JSON [#13904](https://github.com/apache/apisix/pull/13904) - feat(control-api): report the health checks a plugin owns, so `ai-proxy-multi` instance checkers show up in `/v1/healthcheck` [#13899](https://github.com/apache/apisix/pull/13899) From b152cf54af437ad86cab5efb90757a762b34dae4 Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Tue, 22 Sep 2026 12:50:19 +0545 Subject: [PATCH 4/5] chore: give #13977 its own 3.19.0 changelog entry --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcb8c876d5b3..e396d6760eaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,7 +104,8 @@ title: Changelog - feat(stream): support TLS passthrough on the stream proxy, so a stream route can pick its upstream from the SNI in the prereaded ClientHello and still forward the session encrypted [#13912](https://github.com/apache/apisix/pull/13912) - feat(stream): match a stream route by several SNIs through the new `snis` field, mutually exclusive with `sni` [#13911](https://github.com/apache/apisix/pull/13911) -- feat(websocket): add the `ws`/`wss` upstream scheme, which proxies frames through APISIX itself and exposes the `ws_handshake`, `ws_client_frame`, `ws_upstream_frame` and `ws_close` plugin phases plus the `core.websocket` API [#13939](https://github.com/apache/apisix/pull/13939) [#13977](https://github.com/apache/apisix/pull/13977) +- feat(websocket): add the `ws`/`wss` upstream scheme, which proxies frames through APISIX itself and exposes the `ws_handshake`, `ws_client_frame`, `ws_upstream_frame` and `ws_close` plugin phases plus the `core.websocket` API [#13939](https://github.com/apache/apisix/pull/13939) +- fix(websocket): address review findings on the `ws`/`wss` proxy path: dispatch on `ctx.upstream_scheme` so an inline upstream picked by `traffic-split` takes it, send the Host `proxy_pass` would send and use it as the SNI, answer the client with the subprotocol the upstream selected, retry after a non-101 answer, keep the request URI out of the connect failure log, and reject `tls.ca_certs`, which these schemes cannot apply [#13977](https://github.com/apache/apisix/pull/13977) - feat(upstream): slow start for newly observed upstream nodes via `warm_up_conf` [#13941](https://github.com/apache/apisix/pull/13941) - feat: improve API-driven standalone update reliability: workers report a per-entity configuration digest, `PUT /apisix/admin/configs` accepts a `wait` parameter and answers 200 once every worker has loaded the configuration (202 otherwise), and the shdict format carries the digest outside the JSON [#13904](https://github.com/apache/apisix/pull/13904) - feat(control-api): report the health checks a plugin owns, so `ai-proxy-multi` instance checkers show up in `/v1/healthcheck` [#13899](https://github.com/apache/apisix/pull/13899) From b0601632426ad4811750b7fd1ec80a384e741c8b Mon Sep 17 00:00:00 2001 From: Abhishek Choudhary Date: Tue, 22 Sep 2026 13:24:21 +0545 Subject: [PATCH 5/5] docs: address 3.19.0 release review feedback --- CHANGELOG.md | 14 +++++++------- docs/en/latest/plugins/batch-requests.md | 18 ++++++++++++------ docs/zh/latest/plugins/batch-requests.md | 18 ++++++++++++------ 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e396d6760eaf..03359db9b012 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,12 @@ title: Changelog - :warning: fix(basic-auth): reject an empty consumer password. `password` requires `minLength: 1`, so the Admin API rejects an empty value and a consumer already stored with one fails closed with 401 [#13884](https://github.com/apache/apisix/pull/13884) - :warning: fix(ai-proxy-multi): reject instances that share a name. `instance.name` is the instance identity across the balancer, the health checker, `ai-rate-limiting` and `semantic_opts.fallback`, so a route whose instances share a name is now rejected on write and dropped on reload [#13851](https://github.com/apache/apisix/pull/13851) - :warning: fix(workflow): reject invalid case expressions and missing action conf. A `case` expression that was silently accepted and then matched every request is now a schema error, and `actions` is pinned to exactly one `[name, conf]` pair, so a rule carrying several actions (only the first ever ran) or an action without its conf is rejected on write and dropped on reload [#13862](https://github.com/apache/apisix/pull/13862) +- :warning: feat(websocket): label successful WebSocket upgrades with `request_type=websocket`. On existing `enable_websocket` routes, successful 101 responses change from `traditional_http` to `websocket` in `apisix_http_status`, `apisix_http_latency` and `apisix_bandwidth`; update PromQL selectors, recording rules, dashboards and alerts that filter on the old value. The new `ws`/`wss` proxy path reports the same label [#13909](https://github.com/apache/apisix/pull/13909) + This also includes the cached-variable correction from [#13915](https://github.com/apache/apisix/pull/13915), so a plugin that resolves `$request_type` before the upgrade cannot leave the old value cached. +- :warning: fix(redis): send the TLS SNI and add `redis_server_name`. For the single-node `policy: redis` path with `redis_ssl_verify: true`, `redis_host` (or `redis_server_name`) is now used for hostname verification, so a DNS alias not covered by the certificate fails; set `redis_server_name` to the certificate identity or replace the certificate. Cluster and Sentinel policies are unchanged, and an IP literal sends no SNI [#13938](https://github.com/apache/apisix/pull/13938) +- :warning: fix(feishu-auth, dingtalk-auth): bind the authorization code to the session that started the login. Existing browser flows that only forward the code now return 401; the application serving `redirect_uri` must pass the generated `state` to the identity provider and preserve it on the callback. The header code path (`X-Feishu-Code` / `X-DingTalk-Code`) is unchanged [#13806](https://github.com/apache/apisix/pull/13806) +- :warning: fix(jwe-decrypt): accept JWE tokens that authenticate the protected header, as RFC 7516 requires, and reject an explicitly unsupported `alg` or `enc` with 400. Token generators that emit misleading values must use `alg: dir` and `enc: A256GCM`; legacy no-AAD tokens and headers that omit those fields remain accepted [#13889](https://github.com/apache/apisix/pull/13889) +- :warning: fix(control): always report healthcheck nodes as a JSON array. The JSON type of `nodes` and the top-level `/v1/healthcheck` response changes from `{}` to `[]` when empty, so strict clients must accept an empty array [#13891](https://github.com/apache/apisix/pull/13891) ### Core @@ -107,7 +113,7 @@ title: Changelog - feat(websocket): add the `ws`/`wss` upstream scheme, which proxies frames through APISIX itself and exposes the `ws_handshake`, `ws_client_frame`, `ws_upstream_frame` and `ws_close` plugin phases plus the `core.websocket` API [#13939](https://github.com/apache/apisix/pull/13939) - fix(websocket): address review findings on the `ws`/`wss` proxy path: dispatch on `ctx.upstream_scheme` so an inline upstream picked by `traffic-split` takes it, send the Host `proxy_pass` would send and use it as the SNI, answer the client with the subprotocol the upstream selected, retry after a non-101 answer, keep the request URI out of the connect failure log, and reject `tls.ca_certs`, which these schemes cannot apply [#13977](https://github.com/apache/apisix/pull/13977) - feat(upstream): slow start for newly observed upstream nodes via `warm_up_conf` [#13941](https://github.com/apache/apisix/pull/13941) -- feat: improve API-driven standalone update reliability: workers report a per-entity configuration digest, `PUT /apisix/admin/configs` accepts a `wait` parameter and answers 200 once every worker has loaded the configuration (202 otherwise), and the shdict format carries the digest outside the JSON [#13904](https://github.com/apache/apisix/pull/13904) +- feat: improve API-driven standalone update reliability: workers report application of the configuration digest for each tracked resource type, `PUT /apisix/admin/configs` accepts a `wait` parameter and answers 200 once every worker has loaded the configuration (202 otherwise), and the shdict format carries the digest outside the JSON [#13904](https://github.com/apache/apisix/pull/13904) - feat(control-api): report the health checks a plugin owns, so `ai-proxy-multi` instance checkers show up in `/v1/healthcheck` [#13899](https://github.com/apache/apisix/pull/13899) - chore: upgrade lua-resty-dns-client to 7.1.2, fixing `finalCacheOnly` so a CNAME chain no longer fails with `empty record received` when the answer carries an EDNS(0) OPT record or is not in chain order [#13875](https://github.com/apache/apisix/pull/13875) - fix(etcd): watch from the revision the configuration was read at, so a write made while APISIX is starting is no longer lost [#13917](https://github.com/apache/apisix/pull/13917) @@ -115,11 +121,8 @@ title: Changelog - fix(etcd): do not block writes when the deployment role cannot be read [#13885](https://github.com/apache/apisix/pull/13885) - fix(standalone): stop aborting stream connections before the first config arrives [#13855](https://github.com/apache/apisix/pull/13855) - fix(standalone): harden the declarative configuration paths: validate the shape of the request body instead of 500ing, log the parser error rather than the body (which can carry credentials and private keys), check a stream route's `superior_id` self reference during validation, and guard null deployment sections in the CLI [#13886](https://github.com/apache/apisix/pull/13886) -- fix(control): always report healthcheck nodes as a JSON array, so `nodes` and the top-level list of `/v1/healthcheck` are `[]` instead of `{}` when empty [#13891](https://github.com/apache/apisix/pull/13891) - fix(plugin): align unavailable plugin handling: reject unknown plugin names before persistence, keep data-plane loading tolerant of them, and warn when one is skipped [#13928](https://github.com/apache/apisix/pull/13928) - fix: preserve servlet upstream URI boundaries by encoding the original path before proxying when servlet-style normalization is enabled [#13914](https://github.com/apache/apisix/pull/13914) -- feat: label WebSocket sessions with `request_type=websocket`. A request answered with `101 Switching Protocols` is reported as `websocket` instead of `traditional_http` in `apisix_http_status`, `apisix_http_latency` and `apisix_bandwidth`, so a session can be kept out of latency queries [#13909](https://github.com/apache/apisix/pull/13909) -- fix: write `request_type=websocket` through `ctx.var`, so a plugin that resolved `$request_type` before the upgrade does not leave the cached value at `traditional_http` [#13915](https://github.com/apache/apisix/pull/13915) ### Plugins @@ -129,15 +132,12 @@ title: Changelog - feat: chaitin-waf response logging through `log_resp`, `resp_body_size` and `extra_ignored_content_types`, reported asynchronously after the response has been handed back to the client [#13763](https://github.com/apache/apisix/pull/13763) - feat(ai-proxy-multi): let configured HTTP statuses trigger a fallback via `fallback_http_statuses` [#13852](https://github.com/apache/apisix/pull/13852) - feat(saml-auth): add the lua-resty-saml 0.2.6 validation options `idp_issuers`, `sp_acs_url`, `sp_audiences`, `clock_skew`, `replay_dict` and `replay_ttl` [#13964](https://github.com/apache/apisix/pull/13964) -- fix(redis): send the TLS SNI and add `redis_server_name`, so a Redis behind a name-routed TLS front works with `redis_ssl: true`; the SNI is skipped for an IP literal host [#13938](https://github.com/apache/apisix/pull/13938) - fix(ai-proxy): return 502 when a streaming upstream produces no output, instead of falling through to `balancer_by_lua` and answering nothing [#13870](https://github.com/apache/apisix/pull/13870) - fix(ai-proxy): do not turn a streaming read error after partial output into a 5xx, and do not retry a request whose partial output already reached the client [#13876](https://github.com/apache/apisix/pull/13876) - fix(ai-proxy): avoid aborting streams on empty flushes [#13947](https://github.com/apache/apisix/pull/13947) - fix(ai-providers): encode the Vertex AI model path segment [#13872](https://github.com/apache/apisix/pull/13872) - fix(ai-cache): key the passthrough protocol on the client method, path and query, so two upstream endpoints no longer collide on one cache entry [#13887](https://github.com/apache/apisix/pull/13887) - fix(ai-aliyun-content-moderation): report final results without usage [#13922](https://github.com/apache/apisix/pull/13922) -- fix(feishu-auth, dingtalk-auth): bind the authorization code to the session that started the login. A random `state` is appended to the `redirect_uri` redirect and required back on the callback, so a code obtained elsewhere is no longer accepted on any session. Whatever serves `redirect_uri` has to pass `state` on to the identity provider; the header code path (`X-Feishu-Code` / `X-DingTalk-Code`) is unchanged [#13806](https://github.com/apache/apisix/pull/13806) -- fix(jwe-decrypt): accept JWE tokens that authenticate the protected header, as RFC 7516 requires, and reject an unsupported `alg` or `enc` [#13889](https://github.com/apache/apisix/pull/13889) - fix(jwe-decrypt): reject malformed tokens with 400 instead of returning 500 [#13844](https://github.com/apache/apisix/pull/13844) - fix(basic-auth): split credentials on the first colon only, so a password containing `:` is no longer truncated [#13836](https://github.com/apache/apisix/pull/13836) - fix(data-mask): keep request header masking effective in the log phase, where `set_header()` silently did nothing on a 400 response [#13839](https://github.com/apache/apisix/pull/13839) diff --git a/docs/en/latest/plugins/batch-requests.md b/docs/en/latest/plugins/batch-requests.md index fd5be40dd55f..90bc1eb14f1c 100644 --- a/docs/en/latest/plugins/batch-requests.md +++ b/docs/en/latest/plugins/batch-requests.md @@ -69,7 +69,7 @@ plugins: ## Configuration -By default, the maximum body size that can be sent to `/apisix/batch-requests` can't be larger than 1 MiB. You can change this configuration of the Plugin through the endpoint `apisix/admin/plugin_metadata/batch-requests`: +By default, the maximum body size that can be sent to `/apisix/batch-requests` and the maximum response body size for each pipeline request are both 1 MiB. The maximum total response body size for a pipeline is 10 MiB. You can change these global Plugin metadata settings through the endpoint `/apisix/admin/plugin_metadata/batch-requests`: :::note You can fetch the `admin_key` from `config.yaml` and save to an environment variable with the following command: @@ -83,16 +83,22 @@ admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 's/"/ ```shell curl http://127.0.0.1:9180/apisix/admin/plugin_metadata/batch-requests -H "X-API-KEY: $admin_key" -X PUT -d ' { - "max_body_size": 4194304 + "max_body_size": 4194304, + "max_response_body_size": 2097152, + "max_response_body_size_total": 20971520 }' ``` +These metadata settings are global and apply to every request handled by the `batch-requests` Plugin. + ## Metadata -| Name | Type | Required | Default | Valid values | Description | -| ------------------ | ------- | -------- | ------- | ------------ | -------------------------------------------------------- | -| max_body_size | integer | True | 1048576 | [1, ...] | Maximum size of the request body in bytes. | -| max_pipeline_items | integer | True | 1000 | [1, ...] | Maximum number of requests allowed in a single pipeline. | +| Name | Type | Required | Default | Valid values | Description | +| ---------------------------- | ------- | -------- | -------- | ------------ | ------------------------------------------------------------------ | +| max_body_size | integer | True | 1048576 | [1, ...] | Maximum size of the request body in bytes. | +| max_pipeline_items | integer | True | 1000 | [1, ...] | Maximum number of requests allowed in a single pipeline. | +| max_response_body_size | integer | False | 1048576 | [1, ...] | Maximum response body size in bytes for each pipeline request. | +| max_response_body_size_total | integer | False | 10485760 | [1, ...] | Maximum total response body size in bytes for a single pipeline. | ## Request and response format diff --git a/docs/zh/latest/plugins/batch-requests.md b/docs/zh/latest/plugins/batch-requests.md index d43e9abe3c1c..68eec33f6d88 100644 --- a/docs/zh/latest/plugins/batch-requests.md +++ b/docs/zh/latest/plugins/batch-requests.md @@ -69,7 +69,7 @@ plugins: ## 配置插件 -默认情况下,可以发送到 `/apisix/batch-requests` 的最大请求体不能大于 1 MiB。你可以通过 `apisix/admin/plugin_metadata/batch-requests` 更改插件的此配置: +默认情况下,可以发送到 `/apisix/batch-requests` 的最大请求体和每个 pipeline 请求的最大响应体均为 1 MiB,单个 pipeline 的响应体总大小上限为 10 MiB。你可以通过 `/apisix/admin/plugin_metadata/batch-requests` 更改这些全局插件元数据配置: :::note @@ -85,16 +85,22 @@ admin_key=$(yq '.deployment.admin.admin_key[0].key' conf/config.yaml | sed 's/"/ curl http://127.0.0.1:9180/apisix/admin/plugin_metadata/batch-requests \ -H "X-API-KEY: $admin_key" -X PUT -d ' { - "max_body_size": 4194304 + "max_body_size": 4194304, + "max_response_body_size": 2097152, + "max_response_body_size_total": 20971520 }' ``` +这些元数据配置在全局范围内生效,并应用于 `batch-requests` 插件处理的所有请求。 + ## 元数据 -| 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述 | -| ------------------ | ------- | -------| ------- | ------ | ---------------------------- | -| max_body_size | integer | 是 | 1048576 |[1, ...]| 请求体的最大大小,单位:bytes。 | -| max_pipeline_items | integer | 是 | 1000 |[1, ...]| 单个 pipeline 中允许的最大请求数量。 | +| 名称 | 类型 | 必选项 | 默认值 | 有效值 | 描述 | +| ---------------------------- | ------- | ------ | -------- | -------- | ------------------------------------------------ | +| max_body_size | integer | 是 | 1048576 | [1, ...] | 请求体的最大大小,单位:bytes。 | +| max_pipeline_items | integer | 是 | 1000 | [1, ...] | 单个 pipeline 中允许的最大请求数量。 | +| max_response_body_size | integer | 否 | 1048576 | [1, ...] | 每个 pipeline 请求的最大响应体大小,单位:bytes。 | +| max_response_body_size_total | integer | 否 | 10485760 | [1, ...] | 单个 pipeline 的最大响应体总大小,单位:bytes。 | ## 请求和响应格式