From 9a53b4233b829db99aa36d6c751380d6525d50ab Mon Sep 17 00:00:00 2001 From: Michael Martin <3277009+flrgh@users.noreply.github.com> Date: Fri, 25 Mar 2022 12:10:53 -0700 Subject: [PATCH 1/4] optimization: check ssl_support early --- lib/resty/websocket/client.lua | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/resty/websocket/client.lua b/lib/resty/websocket/client.lua index 1221f4a..fec8fe3 100644 --- a/lib/resty/websocket/client.lua +++ b/lib/resty/websocket/client.lua @@ -91,8 +91,13 @@ function _M.connect(self, uri, opts) -- ngx.say("host: ", host) -- ngx.say("port: ", port) + local ssl = scheme == "wss" + if ssl and not ssl_support then + return nil, "ngx_lua 0.9.11+ required for SSL sockets" + end + if not port then - port = scheme == 'wss' and 443 or 80 + port = ssl and 443 or 80 end if path == "" then @@ -134,9 +139,6 @@ function _M.connect(self, uri, opts) end if opts.ssl_verify or opts.server_name then - if not ssl_support then - return nil, "ngx_lua 0.9.11+ required for SSL sockets" - end ssl_verify = opts.ssl_verify server_name = opts.server_name or host end @@ -159,10 +161,7 @@ function _M.connect(self, uri, opts) return nil, "failed to connect: " .. err end - if scheme == "wss" then - if not ssl_support then - return nil, "ngx_lua 0.9.11+ required for SSL sockets" - end + if ssl then if client_cert then ok, err = sock:setclientcert(client_cert, client_priv_key) if not ok then From 54fdcf25d7bc073c0e8c45df6a0068e434757454 Mon Sep 17 00:00:00 2001 From: Michael Martin <3277009+flrgh@users.noreply.github.com> Date: Fri, 25 Mar 2022 12:32:15 -0700 Subject: [PATCH 2/4] feature: support custom host header in client This allows callers of `client:connect()` to specify a custom host header. This option also overrides the default SNI if not explicitly set. It also adds documentation and tests for the recently-added `server_name` option. --- README.markdown | 24 ++- lib/resty/websocket/client.lua | 27 ++- t/cs.t | 301 ++++++++++++++++++++++++++++++++- 3 files changed, 338 insertions(+), 14 deletions(-) diff --git a/README.markdown b/README.markdown index b4a5649..78387d1 100644 --- a/README.markdown +++ b/README.markdown @@ -396,19 +396,29 @@ SSL handshake if the `wss://` scheme is used. * `client_cert` - Specifies a client certificate chain cdata object that will be used while TLS handshaking with remote server. - These objects can be created using - [ngx.ssl.parse_pem_cert](https://github.com/openresty/lua-resty-core/blob/master/lib/ngx/ssl.md#parse_pem_cert) - function provided by lua-resty-core. + Specifies a client certificate chain cdata object that will be used while TLS handshaking with remote server. + These objects can be created using + [ngx.ssl.parse_pem_cert](https://github.com/openresty/lua-resty-core/blob/master/lib/ngx/ssl.md#parse_pem_cert) + function provided by lua-resty-core. Note that specifying the `client_cert` option requires corresponding `client_priv_key` be provided too. See below. * `client_priv_key` - Specifies a private key corresponds to the `client_cert` option above. - These objects can be created using - [ngx.ssl.parse_pem_priv_key](https://github.com/openresty/lua-resty-core/blob/master/lib/ngx/ssl.md#parse_pem_priv_key) + Specifies a private key corresponds to the `client_cert` option above. + These objects can be created using + [ngx.ssl.parse_pem_priv_key](https://github.com/openresty/lua-resty-core/blob/master/lib/ngx/ssl.md#parse_pem_priv_key) function provided by lua-resty-core. +* `host` + + Specifies the value of the `Host` header sent in the handshake request. If not provided, the `Host` header will be derived from the hostname/address and port in the connection URI. + +* `server_name` + + Specifies the server name (SNI) to use when performing the TLS handshake with the server. If not provided, the `host` value or the `:` from the connection URI will be used. + + + The SSL connection mode (`wss://`) requires at least `ngx_lua` 0.9.11 or OpenResty 1.7.4.1. [Back to TOC](#table-of-contents) diff --git a/lib/resty/websocket/client.lua b/lib/resty/websocket/client.lua index fec8fe3..107c8c5 100644 --- a/lib/resty/websocket/client.lua +++ b/lib/resty/websocket/client.lua @@ -84,7 +84,7 @@ function _M.connect(self, uri, opts) end local scheme = m[1] - local host = m[2] + local addr = m[2] local port = m[3] local path = m[4] @@ -107,6 +107,7 @@ function _M.connect(self, uri, opts) local ssl_verify, server_name, headers, proto_header, origin_header local sock_opts = false local client_cert, client_priv_key + local host if opts then local protos = opts.protocols @@ -138,9 +139,13 @@ function _M.connect(self, uri, opts) "client_priv_key must be provided with client_cert") end - if opts.ssl_verify or opts.server_name then + if opts.ssl_verify then ssl_verify = opts.ssl_verify - server_name = opts.server_name or host + end + + server_name = opts.server_name + if server_name ~= nil and type(server_name) ~= "string" then + return nil, "SSL server_name must be a string" end if opts.headers then @@ -149,13 +154,18 @@ function _M.connect(self, uri, opts) return nil, "custom headers must be a table" end end + + host = opts.host + if host ~= nil and type(host) ~= "string" then + return nil, "custom host header must be a string" + end end local ok, err if sock_opts then - ok, err = sock:connect(host, port, sock_opts) + ok, err = sock:connect(addr, port, sock_opts) else - ok, err = sock:connect(host, port) + ok, err = sock:connect(addr, port) end if not ok then return nil, "failed to connect: " .. err @@ -168,6 +178,8 @@ function _M.connect(self, uri, opts) return nil, "failed to set TLS client certificate: " .. err end end + + server_name = server_name or host or addr ok, err = sock:sslhandshake(false, server_name, ssl_verify) if not ok then return nil, "ssl handshake failed: " .. err @@ -201,8 +213,11 @@ function _M.connect(self, uri, opts) rand(256) - 1) local key = encode_base64(bytes) + + local host_header = host or (addr .. ":" .. port) + local req = "GET " .. path .. " HTTP/1.1\r\nUpgrade: websocket\r\nHost: " - .. host .. ":" .. port + .. host_header .. "\r\nSec-WebSocket-Key: " .. key .. (proto_header or "") .. "\r\nSec-WebSocket-Version: 13" diff --git a/t/cs.t b/t/cs.t index a296d77..81108c2 100644 --- a/t/cs.t +++ b/t/cs.t @@ -6,7 +6,7 @@ use Protocol::WebSocket::Frame; repeat_each(2); -plan tests => repeat_each() * (blocks() * 4 + 13); +plan tests => repeat_each() * (blocks() * 4 + 3); my $pwd = cwd(); @@ -2191,3 +2191,302 @@ received: hello (text) [warn] --- timeout: 10 + + + +=== TEST 30: handshake with default host header +--- 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 .. "/s" + local ok, err = wb:connect(uri) + if not ok then + ngx.say("failed to connect: ", err) + return + end + } + } + + location = /s { + content_by_lua_block { + 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 + ngx.log(ngx.INFO, string.format("host: <%s>", ngx.var.http_host)) + } + } +--- request +GET /c +--- error_log eval +qr/host: <127.0.0.1:\d+>/ + + + +=== TEST 31: handshake with custom host header (without port number) +--- 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 .. "/s" + local ok, err = wb:connect(uri, { host = "client.test" }) + if not ok then + ngx.say("failed to connect: ", err) + return + end + } + } + + location = /s { + content_by_lua_block { + 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 + ngx.log(ngx.INFO, string.format("host: <%s>", ngx.var.http_host)) + } + } +--- request +GET /c +--- error_log +host: + + + +=== TEST 32: handshake with custom host header (with port number) +--- 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 .. "/s" + local ok, err = wb:connect(uri, { host = "client.test:8080" }) + if not ok then + ngx.say("failed to connect: ", err) + return + end + } + } + + location = /s { + content_by_lua_block { + 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 + ngx.log(ngx.INFO, string.format("host: <%s>", ngx.var.http_host)) + } + } +--- request +GET /c +--- error_log +host: + + + + +=== TEST 33: SNI derived from custom host header (without port number) +--- http_config eval: $::HttpConfig +--- 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_block { + local client = require "resty.websocket.client" + local wb, err = client:new() + + local uri = "wss://127.0.0.1:12345/s" + local opts = { + host = "test.com", + ssl_verify = false, + } + local ok, err = wb:connect(uri, opts) + if not ok then + ngx.say("failed to connect: ", err) + return + end + } + } + + location = /s { + content_by_lua_block { + 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 + + ngx.log(ngx.INFO, string.format("host: <%s>", ngx.var.http_host)) + ngx.log(ngx.INFO, "SSL server name: ", ngx.var.ssl_server_name) + } + } +--- request +GET /c +--- error_log +host: +SSL server name: test.com + + + +=== TEST 34: SNI derived from custom host header (with port number) +--- http_config eval: $::HttpConfig +--- 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_block { + local client = require "resty.websocket.client" + local wb, err = client:new() + + local uri = "wss://127.0.0.1:12345/s" + local opts = { + host = "test.com:8443", + ssl_verify = false, + } + local ok, err = wb:connect(uri, opts) + if not ok then + ngx.say("failed to connect: ", err) + return + end + } + } + + location = /s { + content_by_lua_block { + 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 + + ngx.log(ngx.INFO, string.format("host: <%s>", ngx.var.http_host)) + ngx.log(ngx.INFO, "SSL server name: ", ngx.var.ssl_server_name) + } + } +--- request +GET /c +--- error_log +host: +SSL server name: test.com:8443 + + + +=== TEST 35: custom SNI +--- http_config eval: $::HttpConfig +--- config + listen 12345 ssl; + server_name test.com client.test; + ssl_certificate ../../cert/test.crt; + ssl_certificate_key ../../cert/test.key; + server_tokens off; + + location = /c { + content_by_lua_block { + local client = require "resty.websocket.client" + local wb, err = client:new() + + local uri = "wss://127.0.0.1:12345/s" + local opts = { + server_name = "test.com", + ssl_verify = false, + } + local ok, err = wb:connect(uri, opts) + if not ok then + ngx.say("failed to connect: ", err) + return + end + } + } + + location = /s { + content_by_lua_block { + 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 + + ngx.log(ngx.INFO, string.format("host: <%s>", ngx.var.http_host)) + ngx.log(ngx.INFO, "SSL server name: ", ngx.var.ssl_server_name) + } + } +--- request +GET /c +--- error_log eval +[ + qr/host: <127.0.0.1:\d+>/, + "SSL server name: test.com", +] + + + +=== TEST 36: custom SNI and host +--- http_config eval: $::HttpConfig +--- config + listen 12345 ssl; + server_name test.com client.test; + ssl_certificate ../../cert/test.crt; + ssl_certificate_key ../../cert/test.key; + server_tokens off; + + location = /c { + content_by_lua_block { + local client = require "resty.websocket.client" + local wb, err = client:new() + + local uri = "wss://127.0.0.1:12345/s" + local opts = { + host = "client.test", + server_name = "test.com", + ssl_verify = false, + } + local ok, err = wb:connect(uri, opts) + if not ok then + ngx.say("failed to connect: ", err) + return + end + } + } + + location = /s { + content_by_lua_block { + 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 + + ngx.log(ngx.INFO, string.format("host: <%s>", ngx.var.http_host)) + ngx.log(ngx.INFO, "SSL server name: ", ngx.var.ssl_server_name) + } + } +--- request +GET /c +--- error_log +host: +SSL server name: test.com + From 121536e6214df2af16b16d21ba84f82ddbbfdfea Mon Sep 17 00:00:00 2001 From: Michael Martin <3277009+flrgh@users.noreply.github.com> Date: Fri, 25 Mar 2022 12:43:09 -0700 Subject: [PATCH 3/4] feature: custom sec-websocket-key in client This gives the caller the ability to explicitly set the value of the Sec-WebSocket-Key header instead of having it generated randomly. For the most part this is not needed, but it is helpful for certain use-cases like integration tests and reverse proxy applications that need to be as transparent as possible with proxying websocket connections. --- README.markdown | 3 +++ lib/resty/websocket/client.lua | 22 +++++++++++++------ t/cs.t | 39 +++++++++++++++++++++++++++++++++- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/README.markdown b/README.markdown index 78387d1..3340f88 100644 --- a/README.markdown +++ b/README.markdown @@ -417,6 +417,9 @@ SSL handshake if the `wss://` scheme is used. Specifies the server name (SNI) to use when performing the TLS handshake with the server. If not provided, the `host` value or the `:` from the connection URI will be used. +* `key` + + Specifies the value of the `Sec-WebSocket-Key` header in the handshake request. The value should be a base64-encoded, 16 byte string conforming to the client handshake requirements of the [WebSocket RFC](https://datatracker.ietf.org/doc/html/rfc6455#section-4.1). If not provided, a key is randomly generated. The SSL connection mode (`wss://`) requires at least `ngx_lua` 0.9.11 or OpenResty 1.7.4.1. diff --git a/lib/resty/websocket/client.lua b/lib/resty/websocket/client.lua index 107c8c5..13b564b 100644 --- a/lib/resty/websocket/client.lua +++ b/lib/resty/websocket/client.lua @@ -108,6 +108,7 @@ function _M.connect(self, uri, opts) local sock_opts = false local client_cert, client_priv_key local host + local key if opts then local protos = opts.protocols @@ -159,6 +160,11 @@ function _M.connect(self, uri, opts) if host ~= nil and type(host) ~= "string" then return nil, "custom host header must be a string" end + + key = opts.key + if key ~= nil and type(key) ~= "string" then + return nil, "custom Sec-WebSocket-Key must be a string" + end end local ok, err @@ -205,14 +211,16 @@ function _M.connect(self, uri, opts) -- do the websocket handshake: - local bytes = char(rand(256) - 1, rand(256) - 1, rand(256) - 1, - rand(256) - 1, rand(256) - 1, rand(256) - 1, - rand(256) - 1, rand(256) - 1, rand(256) - 1, - rand(256) - 1, rand(256) - 1, rand(256) - 1, - rand(256) - 1, rand(256) - 1, rand(256) - 1, - rand(256) - 1) + if not key then + local bytes = char(rand(256) - 1, rand(256) - 1, rand(256) - 1, + rand(256) - 1, rand(256) - 1, rand(256) - 1, + rand(256) - 1, rand(256) - 1, rand(256) - 1, + rand(256) - 1, rand(256) - 1, rand(256) - 1, + rand(256) - 1, rand(256) - 1, rand(256) - 1, + rand(256) - 1) - local key = encode_base64(bytes) + key = encode_base64(bytes) + end local host_header = host or (addr .. ":" .. port) diff --git a/t/cs.t b/t/cs.t index 81108c2..a1d512d 100644 --- a/t/cs.t +++ b/t/cs.t @@ -6,7 +6,7 @@ use Protocol::WebSocket::Frame; repeat_each(2); -plan tests => repeat_each() * (blocks() * 4 + 3); +plan tests => repeat_each() * (blocks() * 4 + 1); my $pwd = cwd(); @@ -2490,3 +2490,40 @@ GET /c host: SSL server name: test.com + + +=== TEST 37: overriding the Sec-WebSocket-Key header +--- 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 .. "/s" + local opts = { + key = "y7KXwBSpVrxtkR0O+bQt+Q==", + } + local ok, err = wb:connect(uri, opts) + if not ok then + ngx.say("failed to connect: ", err) + return + end + } + } + + location = /s { + content_by_lua_block { + 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 + + ngx.log(ngx.INFO, "key: ", ngx.var.http_sec_websocket_key) + } + } +--- request +GET /c +--- error_log +key: y7KXwBSpVrxtkR0O+bQt+Q== From a8d8ee72a7185684c493a782f375c8ec41f55694 Mon Sep 17 00:00:00 2001 From: Michael Martin <3277009+flrgh@users.noreply.github.com> Date: Fri, 25 Mar 2022 14:27:54 -0700 Subject: [PATCH 4/4] feature: return the handshake response --- README.markdown | 4 +++ lib/resty/websocket/client.lua | 11 +++++++- t/cs.t | 47 +++++++++++++++++++++++++++++++++- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/README.markdown b/README.markdown index 3340f88..13749b6 100644 --- a/README.markdown +++ b/README.markdown @@ -421,6 +421,10 @@ SSL handshake if the `wss://` scheme is used. Specifies the value of the `Sec-WebSocket-Key` header in the handshake request. The value should be a base64-encoded, 16 byte string conforming to the client handshake requirements of the [WebSocket RFC](https://datatracker.ietf.org/doc/html/rfc6455#section-4.1). If not provided, a key is randomly generated. +* `keep_response` + + If truth-y, the raw, plain-text response (status line and headers) will be returned as the 3rd return value from `connect()` + The SSL connection mode (`wss://`) requires at least `ngx_lua` 0.9.11 or OpenResty 1.7.4.1. diff --git a/lib/resty/websocket/client.lua b/lib/resty/websocket/client.lua index 13b564b..ffa987e 100644 --- a/lib/resty/websocket/client.lua +++ b/lib/resty/websocket/client.lua @@ -109,6 +109,7 @@ function _M.connect(self, uri, opts) local client_cert, client_priv_key local host local key + local keep_response if opts then local protos = opts.protocols @@ -165,6 +166,10 @@ function _M.connect(self, uri, opts) if key ~= nil and type(key) ~= "string" then return nil, "custom Sec-WebSocket-Key must be a string" end + + if opts.keep_response then + keep_response = true + end end local ok, err @@ -255,7 +260,11 @@ function _M.connect(self, uri, opts) return nil, "bad HTTP response status line: " .. header end - return 1 + if not keep_response then + header = nil + end + + return 1, nil, header end diff --git a/t/cs.t b/t/cs.t index a1d512d..a53b305 100644 --- a/t/cs.t +++ b/t/cs.t @@ -6,7 +6,7 @@ use Protocol::WebSocket::Frame; repeat_each(2); -plan tests => repeat_each() * (blocks() * 4 + 1); +plan tests => repeat_each() * (blocks() * 4 - 1); my $pwd = cwd(); @@ -2527,3 +2527,48 @@ SSL server name: test.com GET /c --- error_log key: y7KXwBSpVrxtkR0O+bQt+Q== + + + +=== TEST 38: keeping the server handshake 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 .. "/s" + local opts = { + keep_response = true, + } + local ok, err, res = wb:connect(uri, opts) + if not ok then + ngx.say("failed to connect: ", err) + return + end + + if not res then + ngx.say("no response string") + return + end + ngx.say(res) + } + } + + location = /s { + content_by_lua_block { + ngx.header["x-test"] = "test" + ngx.header["x-multi"] = { "one", "two" } + + 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 + } + } +--- request +GET /c +--- response_body_like +^HTTP/1\.1 101 Switching Protocols.*