From be7dfaa446d978ebf42040e9ce6c0b61cf123411 Mon Sep 17 00:00:00 2001 From: Wangchong Zhou Date: Tue, 22 Nov 2022 01:27:42 +0800 Subject: [PATCH 01/11] feature: forward proxy (#9) --- lib/resty/websocket/client.lua | 77 +++++++++++- t/cs.t | 216 +++++++++++++++++++++++++++++++++ t/forward-proxy-server.lua | 124 +++++++++++++++++++ 3 files changed, 415 insertions(+), 2 deletions(-) create mode 100644 t/forward-proxy-server.lua diff --git a/lib/resty/websocket/client.lua b/lib/resty/websocket/client.lua index e24cc05..97c2b02 100644 --- a/lib/resty/websocket/client.lua +++ b/lib/resty/websocket/client.lua @@ -19,6 +19,7 @@ local encode_base64 = ngx.encode_base64 local concat = table.concat local char = string.char local str_find = string.find +local str_sub = string.sub local rand = math.random local rshift = bit.rshift local band = bit.band @@ -191,11 +192,47 @@ function _M.connect(self, uri, opts) end end + local connect_addr, connect_port = addr, port + local proxy_opts = opts and opts.proxy_opts + local proxy_url + + if scheme == "wss" and proxy_opts and proxy_opts.wss_proxy then + proxy_url = proxy_opts.wss_proxy + end + + if proxy_url then + if str_sub(proxy_url, 1, 6) == "unix:/" then + connect_addr = proxy_url + connect_port = nil + + else + -- https://github.com/ledgetech/lua-resty-http/blob/master/lib/resty/http.lua + local m, err = re_match( + proxy_url, + [[^(?:(http[s]?):)?//((?:[^\[\]:/\?]+)|(?:\[.+\]))(?::(\d+))?([^\?]*)\??(.*)]], + "jo" + ) + if err then + return nil, "error parsing proxy_url: " .. err + + elseif m[1] ~= "http" and m[1] ~= "https" then + return nil, "only proxy with scheme \"http\" or \"https\" is supported" + end + + connect_addr = m[2] + connect_port = m[3] or 443 + end + + if not connect_addr then + return nil, "invalid proxy url" + end + end + local ok, err if is_unix then - ok, err = sock:connect(addr, sock_opts) + ok, err = sock:connect(connect_addr, sock_opts) else - ok, err = sock:connect(addr, port, sock_opts) + ok, err = sock:connect(connect_addr, connect_port, sock_opts) end if not ok then return nil, "failed to connect: " .. err @@ -213,6 +250,42 @@ function _M.connect(self, uri, opts) end if ssl then + if proxy_url then + local req = "CONNECT " .. addr .. ":" .. port .. " HTTP/1.1" + .. "\r\nHost: " .. addr .. ":" .. port + .. "\r\nProxy-Connection: Keep-Alive" + + if proxy_opts.wss_proxy_authorization then + req = req .. "\r\nProxy-Authorization: " .. proxy_opts.wss_proxy_authorization + end + + req = req .. "\r\n\r\n" + + local bytes, err = sock:send(req) + if not bytes then + return nil, "failed to send the handshake request: " .. err + end + + local header_reader = sock:receiveuntil("\r\n\r\n") + -- FIXME: check for too big response headers + local header, err, _ = header_reader() + if not header then + return nil, "failed to receive response header: " .. err + end + + -- error("header: " .. header) + + -- FIXME: verify the response headers + + local m, _ = re_match(header, [[^\s*HTTP/1\.1\s+(\d+)]], "jo") + if not m then + return nil, "bad HTTP response status line: " .. header + elseif m[1] ~= "200" then + return nil, "error establishing a connection to ".. + "the proxy server, got status " .. tostring(m[1]) + end + end + if client_cert then ok, err = sock:setclientcert(client_cert, client_priv_key) if not ok then diff --git a/t/cs.t b/t/cs.t index d65918c..a506915 100644 --- a/t/cs.t +++ b/t/cs.t @@ -2695,3 +2695,219 @@ received text frame: reused connection --- no_error_log [error] [warn] + + + +=== TEST 41: SSL with forward proxy +--- no_check_leak +--- http_config eval: $::HttpConfig +--- main_config + stream { + server { + listen 16796; + + error_log logs/error.log debug; + content_by_lua_block { + require("t.forward-proxy-server").connect() + } + } + } +--- config + listen 12345 ssl; + server_name test.com; + ssl_certificate ../../cert/test.crt; + ssl_certificate_key ../../cert/test.key; + server_tokens off; + + location = /c { + content_by_lua ' + local client = require "resty.websocket.client" + local wb, err = client:new() + + local uri = "wss://127.0.0.1:12345/s" + local ok, err = wb:connect(uri, { + proxy_opts = { + wss_proxy = "http://127.0.0.1:16796", + }, + }) + if not ok then + ngx.say("failed to connect: " .. err) + return + end + + local data = "hello" + local bytes, err = wb:send_text(data) + if not bytes then + ngx.say("failed to send frame: ", err) + return + end + + local typ + data, typ, err = wb:recv_frame() + if not data then + ngx.say("failed to receive 2nd frame: ", err) + return + end + + ngx.say("received: ", data, " (", typ, ")") + + -- note our mock forward proxy server does not support + -- keepalive, so we must close it here + local ok, err = wb:close() + if not ok then + ngx.say("failed to close conn: ", err) + return + end + '; + } + + location = /s { + content_by_lua ' + local server = require "resty.websocket.server" + local wb, err = server:new() + if not wb then + ngx.log(ngx.ERR, "failed to new websocket: ", err) + return ngx.exit(444) + end + + while true do + local data, typ, err = wb:recv_frame() + if not data then + -- ngx.log(ngx.ERR, "failed to receive a frame: ", err) + return ngx.exit(444) + end + + -- send it back! + local bytes, err = wb:send_text(data) + if not bytes then + ngx.log(ngx.ERR, "failed to send the 2nd text: ", err) + return ngx.exit(444) + end + end + '; + } +--- request +GET /c +--- response_body +received: hello (text) + +--- no_error_log +[error] +[warn] + + + +=== TEST 42: SSL with forward proxy with auth +--- no_check_leak +--- http_config eval: $::HttpConfig +--- main_config + stream { + server { + listen 16796; + + error_log logs/error.log debug; + content_by_lua_block { + require("t.forward-proxy-server").connect({ + basic_auth = ngx.encode_base64("user:pass"), + }) + } + } + } +--- config + listen 12345 ssl; + server_name test.com; + ssl_certificate ../../cert/test.crt; + ssl_certificate_key ../../cert/test.key; + server_tokens off; + + location = /c { + content_by_lua ' + local client = require "resty.websocket.client" + local wb, err = client:new() + + local uri = "wss://127.0.0.1:12345/s" + local ok, err = wb:connect(uri, { + proxy_opts = { + wss_proxy = "http://127.0.0.1:16796", + }, + }) + if ok then + ngx.say("connect ok") + return + end + ngx.say("failed to connect without auth: " .. err) + + local uri = "wss://127.0.0.1:12345/s" + local ok, err = wb:connect(uri, { + proxy_opts = { + wss_proxy = "http://127.0.0.1:16796", + wss_proxy_authorization = "Basic " .. ngx.encode_base64("user:pass") + }, + }) + if not ok then + ngx.say("failed to connect: " .. err) + return + end + + local data = "hello" + local bytes, err = wb:send_text(data) + if not bytes then + ngx.say("failed to send frame: ", err) + return + end + + local typ + data, typ, err = wb:recv_frame() + if not data then + ngx.say("failed to receive 2nd frame: ", err) + return + end + + ngx.say("received: ", data, " (", typ, ")") + + -- note our mock forward proxy server does not support + -- keepalive, so we must close it here + local ok, err = wb:close() + if not ok then + ngx.say("failed to close conn: ", err) + return + end + '; + } + + location = /s { + content_by_lua ' + local server = require "resty.websocket.server" + local wb, err = server:new() + if not wb then + ngx.log(ngx.ERR, "failed to new websocket: ", err) + return ngx.exit(444) + end + + while true do + local data, typ, err = wb:recv_frame() + if not data then + -- ngx.log(ngx.ERR, "failed to receive a frame: ", err) + return ngx.exit(444) + end + + -- send it back! + local bytes, err = wb:send_text(data) + if not bytes then + ngx.log(ngx.ERR, "failed to send the 2nd text: ", err) + return ngx.exit(444) + end + end + '; + } +--- request +GET /c +--- response_body +failed to connect without auth: error establishing a connection to the proxy server, got status 401 +received: hello (text) + +--- no_error_log +[error] +[warn] + + diff --git a/t/forward-proxy-server.lua b/t/forward-proxy-server.lua new file mode 100644 index 0000000..b026605 --- /dev/null +++ b/t/forward-proxy-server.lua @@ -0,0 +1,124 @@ +local _M = {} + +local split = require("ngx.re").split + +local header_mt = { + __index = function(self, name) + name = name:lower():gsub("_", "-") + return rawget(self, name) + end, + + __newindex = function(self, name, value) + name = name:lower():gsub("_", "-") + rawset(self, name, value) + end, +} + +local function new_headers() + return setmetatable({}, header_mt) +end + +-- This is a very naive forward proxy, which accepts a CONNECT over HTTP, and +-- then starts tunnelling the bytes blind (for end-to-end SSL). +function _M.connect(opts) + local req_sock = ngx.req.socket(true) + req_sock:settimeouts(1000, 1000, 1000) + + -- receive request line + local req_line = req_sock:receive() + ngx.log(ngx.DEBUG, "request line: ", req_line) + + local method, host_port = unpack(split(req_line, " ")) + if method ~= "CONNECT" then + return ngx.exit(400) + end + + local upstream_host, upstream_port = unpack(split(host_port, ":")) + + local headers = new_headers() + + -- receive headers + repeat + local line = req_sock:receive("*l") + local name, value = line:match("^([^:]+):%s*(.+)$") + if name and value then + ngx.log(ngx.DEBUG, "header: ", name, " => ", value) + headers[name] = value + end + until ngx.re.find(line, "^\\s*$", "jo") + + + local basic_auth = opts and opts.basic_auth + if basic_auth then + ngx.log(ngx.DEBUG, "checking proxy-authorization...") + + local found = headers["proxy-authorization"] + if not found then + ngx.log(ngx.NOTICE, "client did not send proxy-authorization header") + ngx.print("HTTP/1.1 401 Unauthorized\r\n\r\n") + return ngx.exit(ngx.OK) + end + + local auth = ngx.re.gsub(found, [[^Basic\s*]], "", "oji") + + if auth ~= basic_auth then + ngx.log(ngx.NOTICE, "client sent incorrect proxy-authorization") + ngx.print("HTTP/1.1 403 Forbidden\r\n\r\n") + return ngx.exit(ngx.OK) + end + + ngx.log(ngx.DEBUG, "accepted basic proxy-authorization") + end + + + -- Connect to requested upstream + local upstream_sock = ngx.socket.tcp() + upstream_sock:settimeouts(1000, 1000, 1000) + local ok, err = upstream_sock:connect(upstream_host, upstream_port) + if not ok then + ngx.log(ngx.ERR, "connect to upstream ", upstream_host, ":", upstream_port, + " failed: ", err) + return ngx.exit(504) + end + + -- Tell the client we are good to go + ngx.print("HTTP/1.1 200 OK\r\n\r\n") + ngx.flush() + + ngx.log(ngx.DEBUG, "tunneling started") + + -- 10Kb in either direction should be plenty + local max_bytes = 10 * 1024 + + repeat + local req_data = req_sock:receiveany(max_bytes) + if req_data then + ngx.log(ngx.DEBUG, "client RCV ", #req_data, " bytes") + + local bytes, err = upstream_sock:send(req_data) + if bytes then + ngx.log(ngx.DEBUG, "upstream SND ", bytes, " bytes") + elseif err then + ngx.log(ngx.ERR, "upstream SND failed: ", err) + end + end + + local res_data = upstream_sock:receiveany(max_bytes) + if res_data then + ngx.log(ngx.DEBUG, "upstream RCV ", #res_data, " bytes") + + local bytes, err = req_sock:send(res_data) + if bytes then + ngx.log(ngx.DEBUG, "client SND: ", bytes, " bytes") + elseif err then + ngx.log(ngx.ERR, "client SND failed: ", err) + end + end + until not req_data and not res_data -- request socket should be closed + + upstream_sock:close() + + ngx.log(ngx.DEBUG, "tunneling ended") +end + +return _M From c4d29d8c114a46a50fc7ebacd3e65945e9114f71 Mon Sep 17 00:00:00 2001 From: Chrono Date: Wed, 24 Apr 2024 22:33:04 +0800 Subject: [PATCH 02/11] feat(client): new method `get_resp_headers()` (#16) KAG-4291 --- lib/resty/websocket/client.lua | 51 ++++++++++++++++++++++++++++ t/count.t | 2 +- t/cs.t | 62 ++++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) diff --git a/lib/resty/websocket/client.lua b/lib/resty/websocket/client.lua index 97c2b02..9958cdf 100644 --- a/lib/resty/websocket/client.lua +++ b/lib/resty/websocket/client.lua @@ -15,8 +15,10 @@ local new_tab = wbproto.new_tab local tcp = ngx.socket.tcp local re_match = ngx.re.match local re_find = ngx.re.find +local re_gmatch = ngx.re.gmatch local encode_base64 = ngx.encode_base64 local concat = table.concat +local insert = table.insert local char = string.char local str_find = string.find local str_sub = string.sub @@ -28,6 +30,7 @@ local type = type local debug = ngx.config.debug local ngx_log = ngx.log local ngx_DEBUG = ngx.DEBUG +local tostring = tostring local assert = assert local ssl_support = true @@ -361,6 +364,8 @@ function _M.connect(self, uri, opts) .. m[1], header end + self.resp_header = header + return 1, nil, header end @@ -496,4 +501,50 @@ function _M.set_keepalive(self, ...) end +function _M.get_resp_headers(self) + if self.resp_headers then + return self.resp_headers + end + + local iter, err = re_gmatch(self.resp_header .. "\r\n", "([^:\\s]+):\\s*(.*?)\r\n", "jo") + if err then + return nil, "failed to parse response header: " .. err + end + + -- gather all response headers + + local resp_headers = {} + + while true do + local m, err = iter() + if err then + return nil, "failed to parse response header: " .. err + end + + if not m then + -- no match found (any more) + break + end + + local key = m[1]:lower():gsub("-", "_") + local val = m[2] + + if resp_headers[key] then + if type(resp_headers[key]) ~= "table" then + resp_headers[key] = { resp_headers[key] } + end + + insert(resp_headers[key], tostring(val)) + + else + resp_headers[key] = tostring(val) + end + end + + self.resp_headers = resp_headers + + return resp_headers +end + + return _M diff --git a/t/count.t b/t/count.t index f0be407..f69e5af 100644 --- a/t/count.t +++ b/t/count.t @@ -63,7 +63,7 @@ size: 5 --- request GET /t --- response_body -size: 13 +size: 14 --- no_error_log [error] diff --git a/t/cs.t b/t/cs.t index a506915..8929716 100644 --- a/t/cs.t +++ b/t/cs.t @@ -2911,3 +2911,65 @@ received: hello (text) [warn] +=== TEST 43: client:get_resp_headers +--- http_config eval: $::HttpConfig +--- config + location = /c { + content_by_lua ' + local client = require "resty.websocket.client" + local wb, err = client:new() + local uri = "ws://127.0.0.1:" .. ngx.var.server_port .. "/s" + -- ngx.say("uri: ", uri) + local ok, err = wb:connect(uri) + if not ok then + ngx.say("failed to connect: " .. err) + return + end + + local data, typ, err = wb:recv_frame() + if not data then + ngx.say("failed to receive 1st frame: ", err) + return + end + + ngx.say("1: received: ", data, " (", typ, ")") + + local resp_headers = wb:get_resp_headers() + + ngx.say(resp_headers.upgrade) + ngx.say(resp_headers.connection) + ngx.say(resp_headers.x_foo) + '; + } + + location = /s { + content_by_lua ' + local server = require "resty.websocket.server" + + ngx.header["x-foo"] = "bar" + + local wb, err = server:new() + if not wb then + ngx.log(ngx.ERR, "failed to new websocket: ", err) + return ngx.exit(444) + end + + local bytes, err = wb:send_text("你好, WebSocket!") + if not bytes then + ngx.log(ngx.ERR, "failed to send the 1st text: ", err) + return ngx.exit(444) + end + '; + } +--- request +GET /c +--- response_body +1: received: 你好, WebSocket! (text) +websocket +upgrade +bar +--- no_error_log +[error] +[warn] + + From 04d646cc4623938b4a2955afd072043133a0071f Mon Sep 17 00:00:00 2001 From: Keery Nie Date: Tue, 5 Aug 2025 17:07:44 +0800 Subject: [PATCH 03/11] fix(proxy): support legacy http proxy server http/1.0 response for CONNECT (#19) There are some cases where the websocket connection may go through a legacy http proxy server which only supports HTTP/1.0. When doing CONNECT request, the http proxy server will respond with HTTP/1.0 200 and that may fail the status line check in the current code. This PR loosen this a bit to support both HTTP/1.1 and HTTP/1.0 response for the CONNECT request. https://konghq.atlassian.net/browse/FTI-6683 --- lib/resty/websocket/client.lua | 2 +- t/cs.t | 99 ++++++++++++++++++++++++++++++++++ t/forward-proxy-server.lua | 13 +++-- 3 files changed, 110 insertions(+), 4 deletions(-) diff --git a/lib/resty/websocket/client.lua b/lib/resty/websocket/client.lua index 9958cdf..28d5d99 100644 --- a/lib/resty/websocket/client.lua +++ b/lib/resty/websocket/client.lua @@ -280,7 +280,7 @@ function _M.connect(self, uri, opts) -- FIXME: verify the response headers - local m, _ = re_match(header, [[^\s*HTTP/1\.1\s+(\d+)]], "jo") + local m, _ = re_match(header, [[^\s*HTTP/1\.[01]\s+(\d+)]], "jo") if not m then return nil, "bad HTTP response status line: " .. header elseif m[1] ~= "200" then diff --git a/t/cs.t b/t/cs.t index 8929716..afaa845 100644 --- a/t/cs.t +++ b/t/cs.t @@ -2973,3 +2973,102 @@ bar [warn] + +=== TEST 44: SSL with forward proxy and legacy HTTP version +--- no_check_leak +--- http_config eval: $::HttpConfig +--- main_config + stream { + server { + listen 16796; + + error_log logs/error.log debug; + content_by_lua_block { + require("t.forward-proxy-server").connect({ + legacy_http_version = true + }) + } + } + } +--- config + listen 12345 ssl; + server_name test.com; + ssl_certificate ../../cert/test.crt; + ssl_certificate_key ../../cert/test.key; + server_tokens off; + + location = /c { + content_by_lua ' + local client = require "resty.websocket.client" + local wb, err = client:new() + + local uri = "wss://127.0.0.1:12345/s" + local ok, err = wb:connect(uri, { + proxy_opts = { + wss_proxy = "http://127.0.0.1:16796", + }, + }) + if not ok then + ngx.say("failed to connect: " .. err) + return + end + + local data = "hello" + local bytes, err = wb:send_text(data) + if not bytes then + ngx.say("failed to send frame: ", err) + return + end + + local typ + data, typ, err = wb:recv_frame() + if not data then + ngx.say("failed to receive 2nd frame: ", err) + return + end + + ngx.say("received: ", data, " (", typ, ")") + + -- note our mock forward proxy server does not support + -- keepalive, so we must close it here + local ok, err = wb:close() + if not ok then + ngx.say("failed to close conn: ", err) + return + end + '; + } + + location = /s { + content_by_lua ' + local server = require "resty.websocket.server" + local wb, err = server:new() + if not wb then + ngx.log(ngx.ERR, "failed to new websocket: ", err) + return ngx.exit(444) + end + + while true do + local data, typ, err = wb:recv_frame() + if not data then + -- ngx.log(ngx.ERR, "failed to receive a frame: ", err) + return ngx.exit(444) + end + + -- send it back! + local bytes, err = wb:send_text(data) + if not bytes then + ngx.log(ngx.ERR, "failed to send the 2nd text: ", err) + return ngx.exit(444) + end + end + '; + } +--- request +GET /c +--- response_body +received: hello (text) + +--- no_error_log +[error] +[warn] diff --git a/t/forward-proxy-server.lua b/t/forward-proxy-server.lua index b026605..94162c9 100644 --- a/t/forward-proxy-server.lua +++ b/t/forward-proxy-server.lua @@ -1,5 +1,6 @@ local _M = {} +local fmt = string.format local split = require("ngx.re").split local header_mt = { @@ -18,6 +19,11 @@ local function new_headers() return setmetatable({}, header_mt) end +local function respond(msg, http_version) + ngx.print(fmt("HTTP/%s %s\r\n\r\n", http_version or "1.1", msg)) +end + + -- This is a very naive forward proxy, which accepts a CONNECT over HTTP, and -- then starts tunnelling the bytes blind (for end-to-end SSL). function _M.connect(opts) @@ -48,6 +54,7 @@ function _M.connect(opts) until ngx.re.find(line, "^\\s*$", "jo") + local http_version = opts and opts.legacy_http_version and "1.0" or "1.1" local basic_auth = opts and opts.basic_auth if basic_auth then ngx.log(ngx.DEBUG, "checking proxy-authorization...") @@ -55,7 +62,7 @@ function _M.connect(opts) local found = headers["proxy-authorization"] if not found then ngx.log(ngx.NOTICE, "client did not send proxy-authorization header") - ngx.print("HTTP/1.1 401 Unauthorized\r\n\r\n") + respond("401 Unauthorized", http_version) return ngx.exit(ngx.OK) end @@ -63,7 +70,7 @@ function _M.connect(opts) if auth ~= basic_auth then ngx.log(ngx.NOTICE, "client sent incorrect proxy-authorization") - ngx.print("HTTP/1.1 403 Forbidden\r\n\r\n") + respond("403 Forbidden", http_version) return ngx.exit(ngx.OK) end @@ -82,7 +89,7 @@ function _M.connect(opts) end -- Tell the client we are good to go - ngx.print("HTTP/1.1 200 OK\r\n\r\n") + respond("200 OK", http_version) ngx.flush() ngx.log(ngx.DEBUG, "tunneling started") From ec9f26edccf26071b1e6f8c6d68e5aa592b4c1f1 Mon Sep 17 00:00:00 2001 From: Jay Jijie Chen <1180092+jijiechen@users.noreply.github.com> Date: Mon, 25 Aug 2025 14:48:30 +0800 Subject: [PATCH 04/11] feat(client): add new client api to get server returned status code (#20) Part of https://konghq.atlassian.net/browse/FTI-6895 We need these new websocket client features to proceed the issue: 1. return the HTTP status code even if the connection fails, so that we can tell if an error is HTTP error or connection level error 2. return the HTTP headers even if the connection fails, so that we can honor the `retry-after` response code when the HTTP status code is 429. --- lib/resty/websocket/client.lua | 14 ++++++------- t/count.t | 2 +- t/cs.t | 37 ++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/lib/resty/websocket/client.lua b/lib/resty/websocket/client.lua index 28d5d99..ccbc4f4 100644 --- a/lib/resty/websocket/client.lua +++ b/lib/resty/websocket/client.lua @@ -357,14 +357,11 @@ function _M.connect(self, uri, opts) return nil, "bad HTTP response status line: " .. header end - -- RFC 6455 section 4.1: a status code other than 101 means the server - -- has not accepted the upgrade, so the client must fail the connection - if m[1] ~= "101" then - return nil, "failed websocket handshake: unexpected response status: " - .. m[1], header - end - + self.resp_status_code = m[1] self.resp_header = header + if self.resp_status_code ~= "101" then + return nil, "unexpected HTTP response code: " .. m[1], header + end return 1, nil, header end @@ -546,5 +543,8 @@ function _M.get_resp_headers(self) return resp_headers end +function _M.get_resp_status_code(self) + return self.resp_status_code +end return _M diff --git a/t/count.t b/t/count.t index f69e5af..eab91fa 100644 --- a/t/count.t +++ b/t/count.t @@ -63,7 +63,7 @@ size: 5 --- request GET /t --- response_body -size: 14 +size: 15 --- no_error_log [error] diff --git a/t/cs.t b/t/cs.t index afaa845..6628c2b 100644 --- a/t/cs.t +++ b/t/cs.t @@ -3072,3 +3072,40 @@ received: hello (text) --- no_error_log [error] [warn] + +=== TEST 45: get_resp_status_code and headers when error connect +--- http_config eval: $::HttpConfig +--- config + location = /c { + content_by_lua ' + local client = require "resty.websocket.client" + local wb, err = client:new() + local uri = "ws://127.0.0.1:" .. ngx.var.server_port .. "/s" + -- ngx.say("uri: ", uri) + local ok, err = wb:connect(uri) + if not ok then + local headers = wb:get_resp_headers() + local status_code = wb:get_resp_status_code() + ngx.say("1: status code: ", status_code) + ngx.say("2: retry-after: ", headers.retry_after) + return + else + ngx.say("websocket should fail") + end + '; + } + + location = /s { + content_by_lua ' + ngx.header["retry-after"] = "30" + return ngx.exit(ngx.HTTP_TOO_MANY_REQUESTS) + '; + } +--- request +GET /c +--- response_body +1: status code: 429 +2: retry-after: 30 +--- no_error_log +[error] +[warn] From 25ca99e8ddb5b6408637b1e10ca46bbd4a343192 Mon Sep 17 00:00:00 2001 From: Xumin <100666470+StarlightIbuki@users.noreply.github.com> Date: Fri, 31 Oct 2025 17:10:41 +0800 Subject: [PATCH 05/11] fix(client): handle the handshake failures correctly (#21) KAG-7707 --- lib/resty/websocket/client.lua | 7 +++++++ t/sanity.t | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/lib/resty/websocket/client.lua b/lib/resty/websocket/client.lua index ccbc4f4..6f5b5c8 100644 --- a/lib/resty/websocket/client.lua +++ b/lib/resty/websocket/client.lua @@ -360,6 +360,13 @@ function _M.connect(self, uri, opts) self.resp_status_code = m[1] self.resp_header = header if self.resp_status_code ~= "101" then + local closing_ok, closing_err = sock:close() + if not closing_ok then + ngx_log(ngx_DEBUG, "failed to close the underlying socket: ", + closing_err, " when handling a non-101 response") + end + + self.fatal = true return nil, "unexpected HTTP response code: " .. m[1], header end diff --git a/t/sanity.t b/t/sanity.t index 46216c6..64c2c72 100644 --- a/t/sanity.t +++ b/t/sanity.t @@ -6,7 +6,7 @@ use Protocol::WebSocket::Frame; repeat_each(2); -plan tests => repeat_each() * 162; +plan tests => repeat_each() * 162 + 6; my $pwd = cwd(); @@ -943,3 +943,34 @@ Sec-WebSocket-Protocol: chat --- no_error_log [error] --- error_code: 101 + + + +=== TEST 23: client should not send a close frame when server responds non-101 +--- http_config eval: $::HttpConfig +--- config + location = /c { + content_by_lua_block { + local client = require "resty.websocket.client" + local wb, err = client:new() + local uri = "ws://127.0.0.1:" .. ngx.var.server_port .. "/plain" + local ok, err, header = wb:connect(uri) + if ok then + ngx.say("unexpected ok: ", header) + return + end + + ngx.say("connect result: ", err) + } + } + + location = /plain { + default_type text/plain; + return 200 'plain http'; + } +--- request +GET /c +--- response_body +connect result: unexpected HTTP response code: 200 +--- no_error_log +[error] From 8501f4f5bc135fd9a28b9676c2d1ec95097f56c2 Mon Sep 17 00:00:00 2001 From: Xumin <100666470+StarlightIbuki@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:09:23 +0800 Subject: [PATCH 06/11] fix(client): keep socket open on non-101 when keep_response=true (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(client): keep socket open on non-101 when keep_response=true When a caller sets `keep_response=true` in the connect options, leave the raw TCP socket open after a non-101 response, so the caller can still read the HTTP response body (RFC 6455 §4.1: "handle the response per HTTP procedures"). `self.fatal` is now set unconditionally for non-101 responses, regardless of `keep_response`, ensuring no WebSocket frames can be sent on a connection that never completed the WebSocket handshake. KAG-7707 * ci: pin all three lua modules to OpenResty 1.21.4.3 bundle versions - lua-nginx-module master: NGX_HTTP_LUA_VERSION undefined/changed, causing ngx.config.ngx_lua_version < 10025 check to fail - stream-lua-nginx-module master: ngx_stream_ssl_srv_conf_t removed - lua-resty-core master: requires stream module >= 0.0.18 --- lib/resty/websocket/client.lua | 16 +++++-- t/sanity.t | 86 +++++++++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 6 deletions(-) diff --git a/lib/resty/websocket/client.lua b/lib/resty/websocket/client.lua index 6f5b5c8..d0a1837 100644 --- a/lib/resty/websocket/client.lua +++ b/lib/resty/websocket/client.lua @@ -360,12 +360,18 @@ function _M.connect(self, uri, opts) self.resp_status_code = m[1] self.resp_header = header if self.resp_status_code ~= "101" then - local closing_ok, closing_err = sock:close() - if not closing_ok then - ngx_log(ngx_DEBUG, "failed to close the underlying socket: ", - closing_err, " when handling a non-101 response") + -- RFC 6455 §4.1: a non-101 response means the WebSocket connection + -- was never established; mark fatal unconditionally so that no WS + -- frames can be sent on what is still a plain HTTP connection. + -- When keep_response=true the caller intends to read the HTTP + -- response body, so leave the raw socket open for that purpose. + if not (opts and opts.keep_response) then + local closing_ok, closing_err = sock:close() + if not closing_ok then + ngx_log(ngx_DEBUG, "failed to close the underlying socket: ", + closing_err, " when handling a non-101 response") + end end - self.fatal = true return nil, "unexpected HTTP response code: " .. m[1], header end diff --git a/t/sanity.t b/t/sanity.t index 64c2c72..5827546 100644 --- a/t/sanity.t +++ b/t/sanity.t @@ -6,7 +6,7 @@ use Protocol::WebSocket::Frame; repeat_each(2); -plan tests => repeat_each() * 162 + 6; +plan tests => repeat_each() * 162 + 18; my $pwd = cwd(); @@ -974,3 +974,87 @@ GET /c connect result: unexpected HTTP response code: 200 --- no_error_log [error] + + + +=== TEST 24: fatal is set and socket is closed after non-101 (default, no keep_response) +--- http_config eval: $::HttpConfig +--- config + location = /c { + content_by_lua_block { + local client = require "resty.websocket.client" + local wb, err = client:new() + local uri = "ws://127.0.0.1:" .. ngx.var.server_port .. "/plain" + local ok, err = wb:connect(uri) + if ok then + ngx.say("unexpected ok") + return + end + + ngx.say("fatal: ", tostring(wb.fatal)) + + local _, send_err = wb:send_text("hello") + ngx.say("send_text: ", send_err) + + local _, _, recv_err = wb:recv_frame() + ngx.say("recv_frame: ", recv_err) + } + } + + location = /plain { + default_type text/plain; + return 200 'plain http'; + } +--- request +GET /c +--- response_body +fatal: true +send_text: fatal error already happened +recv_frame: fatal error already happened +--- no_error_log +[error] + + + +=== TEST 25: keep_response=true leaves socket open for body reading; fatal still set +--- http_config eval: $::HttpConfig +--- config + location = /c { + content_by_lua_block { + local client = require "resty.websocket.client" + local wb, err = client:new() + local uri = "ws://127.0.0.1:" .. ngx.var.server_port .. "/plain" + local ok, err = wb:connect(uri, { keep_response = true }) + if ok then + ngx.say("unexpected ok") + return + end + + ngx.say("fatal: ", tostring(wb.fatal)) + + local _, send_err = wb:send_text("hello") + ngx.say("send_text: ", send_err) + + -- raw socket is still open; read the HTTP response body directly + local body, read_err = wb.sock:receive(10) + if read_err then + ngx.say("body read failed: ", read_err) + else + ngx.say("body: ", body) + end + wb.sock:close() + } + } + + location = /plain { + default_type text/plain; + return 200 'plain http'; + } +--- request +GET /c +--- response_body +fatal: true +send_text: fatal error already happened +body: plain http +--- no_error_log +[error] From f7db8b8ed4824a187f95eaff9fd04cec3c70a595 Mon Sep 17 00:00:00 2001 From: kurt Date: Mon, 18 May 2026 14:02:37 +0800 Subject: [PATCH 07/11] fix(client.lua): add nil check for response headers to handle cases when response header is not available (#25) * fix(client.lua): add nil check for response headers to handle cases when the response header is not available --- lib/resty/websocket/client.lua | 4 ++++ t/cs.t | 39 ++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/lib/resty/websocket/client.lua b/lib/resty/websocket/client.lua index d0a1837..1340f9c 100644 --- a/lib/resty/websocket/client.lua +++ b/lib/resty/websocket/client.lua @@ -516,6 +516,10 @@ function _M.get_resp_headers(self) return self.resp_headers end + if not self.resp_header then + return nil, "response header not available" + end + local iter, err = re_gmatch(self.resp_header .. "\r\n", "([^:\\s]+):\\s*(.*?)\r\n", "jo") if err then return nil, "failed to parse response header: " .. err diff --git a/t/cs.t b/t/cs.t index 6628c2b..c3f437a 100644 --- a/t/cs.t +++ b/t/cs.t @@ -3073,6 +3073,8 @@ received: hello (text) [error] [warn] + + === TEST 45: get_resp_status_code and headers when error connect --- http_config eval: $::HttpConfig --- config @@ -3109,3 +3111,40 @@ GET /c --- no_error_log [error] [warn] + + + +=== TEST 46: get_resp_headers and get_resp_status_code return nil if connect failed without response +--- http_config eval: $::HttpConfig +--- config + location = /c { + content_by_lua ' + local client = require "resty.websocket.client" + local wb, err = client:new() + local uri = "ws://127.0.0.1:" .. ngx.var.server_port .. "/s" + local ok, err = wb:connect(uri) + if not ok then + local headers, err = wb:get_resp_headers() + local status_code = wb:get_resp_status_code() + ngx.say("1: status code: ", status_code) + ngx.say("2: headers: ", headers) + ngx.say("3: error: ", err) + return + end + '; + } + + location = /s { + content_by_lua ' + return ngx.exit(ngx.HTTP_CLOSE) + '; + } +--- request +GET /c +--- response_body +1: status code: nil +2: headers: nil +3: error: response header not available +--- no_error_log +[error] +[warn] From 1ab3bd5572c22b812af1ffd28d8f3067a9f7ad3d Mon Sep 17 00:00:00 2001 From: Zeping Bu Date: Sun, 13 Sep 2026 18:34:56 +0800 Subject: [PATCH 08/11] chore: trigger CI run From 3ead326e427755f4d4d101dae96a9eab1bdcca7d Mon Sep 17 00:00:00 2001 From: Zeping Bu Date: Sun, 13 Sep 2026 18:39:44 +0800 Subject: [PATCH 09/11] ci: build nginx with the stream module for forward-proxy tests The forward-proxy tests ported from Kong/lua-resty-websocket spin up a fake proxy server via an nginx `stream {}` block, which needs the stream core module and stream-lua-nginx-module. Add both to the CI build, matching what Kong's own CI already builds with. --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d931a1d..4e69163 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,7 @@ jobs: git clone https://github.com/openresty/nginx-devel-utils.git git clone https://github.com/simpl/ngx_devel_kit.git ../ndk-nginx-module git clone https://github.com/openresty/lua-nginx-module.git ../lua-nginx-module + git clone https://github.com/openresty/stream-lua-nginx-module.git ../stream-lua-nginx-module git clone https://github.com/openresty/lua-resty-core.git ../lua-resty-core git clone https://github.com/openresty/lua-resty-lrucache.git ../lua-resty-lrucache git clone https://github.com/openresty/no-pool-nginx.git ../no-pool-nginx @@ -76,7 +77,7 @@ jobs: export LD_LIBRARY_PATH=$PWD/mockeagain:$LD_LIBRARY_PATH export TEST_NGINX_RESOLVER=8.8.4.4 export NGX_BUILD_CC=$CC - ngx-build $NGINX_VERSION --with-ipv6 --with-http_realip_module --with-http_ssl_module --with-cc-opt="-I$PCRE_INC -I$OPENSSL_INC" --with-ld-opt="-L$PCRE_LIB -L$OPENSSL_LIB -Wl,-rpath,$PCRE_LIB:$OPENSSL_LIB" --add-module=../ndk-nginx-module --add-module=../lua-nginx-module --with-debug > build.log 2>&1 || (cat build.log && exit 1) + ngx-build $NGINX_VERSION --with-ipv6 --with-http_realip_module --with-http_ssl_module --with-stream --with-stream_ssl_module --with-stream_ssl_preread_module --with-cc-opt="-I$PCRE_INC -I$OPENSSL_INC" --with-ld-opt="-L$PCRE_LIB -L$OPENSSL_LIB -Wl,-rpath,$PCRE_LIB:$OPENSSL_LIB" --add-module=../ndk-nginx-module --add-module=../lua-nginx-module --add-module=../stream-lua-nginx-module --with-debug > build.log 2>&1 || (cat build.log && exit 1) nginx -V ldd `which nginx`|grep -E 'luajit|ssl|pcre' prove -I. -r t From 3069b2c34bd19ef273cc5eac14ea991400adbe45 Mon Sep 17 00:00:00 2001 From: Zeping Bu Date: Sun, 13 Sep 2026 18:44:08 +0800 Subject: [PATCH 10/11] test: update handshake.t to match the adopted error message wording We replaced this repo's own non-101 handshake rejection with Kong's implementation, which uses a different error message ('unexpected HTTP response code: N' instead of 'failed websocket handshake: unexpected response status: N'). Update the pre-existing assertions in handshake.t accordingly. --- t/handshake.t | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/t/handshake.t b/t/handshake.t index 4de53dc..71a7fef 100644 --- a/t/handshake.t +++ b/t/handshake.t @@ -49,7 +49,7 @@ __DATA__ --- request GET /t --- response_body -failed to connect: failed websocket handshake: unexpected response status: 403 +failed to connect: unexpected HTTP response code: 403 --- no_error_log [error] @@ -84,7 +84,7 @@ failed to connect: failed websocket handshake: unexpected response status: 403 --- request GET /t --- response_body -failed to connect: failed websocket handshake: unexpected response status: 301 +failed to connect: unexpected HTTP response code: 301 --- no_error_log [error] From 8c3072b242c6d24de5523cb0455f3cf43b750566 Mon Sep 17 00:00:00 2001 From: Zeping Bu Date: Sun, 13 Sep 2026 19:09:50 +0800 Subject: [PATCH 11/11] fix: address CodeRabbit findings on the ported proxy/response-status code - connect(): clear resp_status_code/resp_header/resp_headers at the start of every attempt, so a client instance reused across multiple connect() calls (pool-reused connection, or a retry after failure) never exposes stale data from a previous attempt. - proxy URL parsing: guard against an unmatched proxy_url indexing a nil match result; explicitly reject an https:// wss_proxy instead of silently treating it like http (TLS to the proxy itself was never implemented, so accepting it would send the CONNECT request and any Proxy-Authorization in the clear while the scheme implied otherwise). With https rejected, the only remaining valid scheme is http, so the proxy's default port is now correctly 80 instead of always 443. - proxy connect(): track whether the actual connect target is a Unix socket independently of whether the original wss:// URI was one, so a Unix-socket wss_proxy used against a non-Unix target now uses the two-argument sock:connect() form instead of passing a nil port. - t/forward-proxy-server.lua: stop logging header values at DEBUG level, since this included the Proxy-Authorization credential. --- lib/resty/websocket/client.lua | 27 +++++++++++++++++++++++---- t/forward-proxy-server.lua | 4 +++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/lib/resty/websocket/client.lua b/lib/resty/websocket/client.lua index 1340f9c..bb95958 100644 --- a/lib/resty/websocket/client.lua +++ b/lib/resty/websocket/client.lua @@ -83,6 +83,13 @@ end function _M.connect(self, uri, opts) + -- a client instance may be reused across multiple connect() attempts + -- (e.g. a failed connect followed by a retry); clear any response + -- metadata from a previous attempt so callers never observe stale data. + self.resp_status_code = nil + self.resp_header = nil + self.resp_headers = nil + local sock = self.sock if not sock then return nil, "not initialized" @@ -196,6 +203,7 @@ function _M.connect(self, uri, opts) end local connect_addr, connect_port = addr, port + local connect_is_unix = is_unix local proxy_opts = opts and opts.proxy_opts local proxy_url @@ -207,6 +215,7 @@ function _M.connect(self, uri, opts) if str_sub(proxy_url, 1, 6) == "unix:/" then connect_addr = proxy_url connect_port = nil + connect_is_unix = true else -- https://github.com/ledgetech/lua-resty-http/blob/master/lib/resty/http.lua @@ -218,12 +227,22 @@ function _M.connect(self, uri, opts) if err then return nil, "error parsing proxy_url: " .. err - elseif m[1] ~= "http" and m[1] ~= "https" then - return nil, "only proxy with scheme \"http\" or \"https\" is supported" + elseif not m then + return nil, "invalid proxy url" + + elseif m[1] == "https" then + -- TLS to the proxy itself (as opposed to the tunnelled TLS + -- handshake with the target once CONNECT succeeds) is not + -- implemented; fail loudly instead of silently sending the + -- CONNECT request (and any Proxy-Authorization) in the clear. + return nil, "https proxy (TLS to the proxy itself) is not implemented" + + elseif m[1] ~= "http" then + return nil, "only proxy with scheme \"http\" is supported" end connect_addr = m[2] - connect_port = m[3] or 443 + connect_port = m[3] or 80 end if not connect_addr then @@ -232,7 +251,7 @@ function _M.connect(self, uri, opts) end local ok, err - if is_unix then + if connect_is_unix then ok, err = sock:connect(connect_addr, sock_opts) else ok, err = sock:connect(connect_addr, connect_port, sock_opts) diff --git a/t/forward-proxy-server.lua b/t/forward-proxy-server.lua index 94162c9..94beabc 100644 --- a/t/forward-proxy-server.lua +++ b/t/forward-proxy-server.lua @@ -48,7 +48,9 @@ function _M.connect(opts) local line = req_sock:receive("*l") local name, value = line:match("^([^:]+):%s*(.+)$") if name and value then - ngx.log(ngx.DEBUG, "header: ", name, " => ", value) + -- don't log header values: this includes Proxy-Authorization, which + -- carries the client's credentials + ngx.log(ngx.DEBUG, "header: ", name) headers[name] = value end until ngx.re.find(line, "^\\s*$", "jo")