diff options
-rw-r--r-- | doc/src/manual/cowboy_websocket.asciidoc | 46 | ||||
-rw-r--r-- | src/cowboy_websocket.erl | 68 | ||||
-rw-r--r-- | test/handlers/ws_deflate_opts_h.erl | 36 | ||||
-rw-r--r-- | test/ws_SUITE.erl | 120 |
4 files changed, 220 insertions, 50 deletions
diff --git a/doc/src/manual/cowboy_websocket.asciidoc b/doc/src/manual/cowboy_websocket.asciidoc index d8b223c..fdaa482 100644 --- a/doc/src/manual/cowboy_websocket.asciidoc +++ b/doc/src/manual/cowboy_websocket.asciidoc @@ -152,6 +152,7 @@ Cowboy does it automatically for you. ---- opts() :: #{ compress => boolean(), + deflate_opts => cow_ws:deflate_opts() idle_timeout => timeout(), max_frame_size => non_neg_integer() | infinity, req_filter => fun((cowboy_req:req()) -> map()) @@ -173,31 +174,44 @@ init(Req, State) -> The default value is given next to the option name: compress (false):: - Whether to enable the Websocket frame compression - extension. Frames will only be compressed for the - clients that support this extension. + +Whether to enable the Websocket frame compression +extension. Frames will only be compressed for the +clients that support this extension. + +deflate_opts (#{}):: + +Configuration for the permessage-deflate Websocket +extension. Allows configuring both the negotiated +options and the zlib compression options. The +defaults optimize the compression at the expense +of some memory and CPU. idle_timeout (60000):: - Time in milliseconds that Cowboy will keep the - connection open without receiving anything from - the client. + +Time in milliseconds that Cowboy will keep the +connection open without receiving anything from +the client. max_frame_size (infinity):: - Maximum frame size allowed by this Websocket - handler. Cowboy will close the connection when - a client attempts to send a frame that goes over - this limit. For fragmented frames this applies - to the size of the reconstituted frame. + +Maximum frame size allowed by this Websocket +handler. Cowboy will close the connection when +a client attempts to send a frame that goes over +this limit. For fragmented frames this applies +to the size of the reconstituted frame. req_filter:: - A function applied to the Req to compact it and - only keep required information. The Req is only - given back in the `terminate/3` callback. By default - it keeps the method, version, URI components and peer - information. + +A function applied to the Req to compact it and +only keep required information. The Req is only +given back in the `terminate/3` callback. By default +it keeps the method, version, URI components and peer +information. == Changelog +* *2.6*: Deflate options can now be configured via `deflate_opts`. * *2.0*: The Req object is no longer passed to Websocket callbacks. * *2.0*: The callback `websocket_terminate/3` was removed in favor of `terminate/3`. * *1.0*: Protocol introduced. diff --git a/src/cowboy_websocket.erl b/src/cowboy_websocket.erl index 913c116..e1cb2d4 100644 --- a/src/cowboy_websocket.erl +++ b/src/cowboy_websocket.erl @@ -66,6 +66,7 @@ -type opts() :: #{ compress => boolean(), + deflate_opts => cow_ws:deflate_opts(), idle_timeout => timeout(), max_frame_size => non_neg_integer() | infinity, req_filter => fun((cowboy_req:req()) -> map()) @@ -77,13 +78,11 @@ ref :: ranch:ref(), socket = undefined :: inet:socket() | {pid(), cowboy_stream:streamid()} | undefined, transport = undefined :: module() | undefined, + opts = #{} :: opts(), active = true :: boolean(), handler :: module(), key = undefined :: undefined | binary(), - timeout = infinity :: timeout(), timeout_ref = undefined :: undefined | reference(), - compress = false :: boolean(), - max_frame_size :: non_neg_integer() | infinity, messages = undefined :: undefined | {atom(), atom(), atom()}, hibernate = false :: boolean(), frag_state = undefined :: cow_ws:frag_state(), @@ -125,15 +124,11 @@ upgrade(Req, Env, Handler, HandlerState) -> when Req::cowboy_req:req(), Env::cowboy_middleware:env(). %% @todo Immediately crash if a response has already been sent. upgrade(Req0=#{version := Version}, Env, Handler, HandlerState, Opts) -> - Timeout = maps:get(idle_timeout, Opts, 60000), - MaxFrameSize = maps:get(max_frame_size, Opts, infinity), - Compress = maps:get(compress, Opts, false), FilteredReq = case maps:get(req_filter, Opts, undefined) of undefined -> maps:with([method, version, scheme, host, port, path, qs, peer], Req0); FilterFun -> FilterFun(Req0) end, - State0 = #state{handler=Handler, timeout=Timeout, compress=Compress, - max_frame_size=MaxFrameSize, req=FilteredReq}, + State0 = #state{opts=Opts, handler=Handler, req=FilteredReq}, try websocket_upgrade(State0, Req0) of {ok, State, Req} -> websocket_handshake(State, Req, HandlerState, Env); @@ -174,13 +169,14 @@ websocket_version(State, Req) -> end, websocket_extensions(State, Req#{websocket_version => WsVersion}). -websocket_extensions(State=#state{compress=Compress}, Req) -> +websocket_extensions(State=#state{opts=Opts}, Req) -> %% @todo We want different options for this. For example %% * compress everything auto %% * compress only text auto %% * compress only binary auto %% * compress nothing auto (but still enabled it) %% * disable compression + Compress = maps:get(compress, Opts, false), case {Compress, cowboy_req:parse_header(<<"sec-websocket-extensions">>, Req)} of {true, Extensions} when Extensions =/= undefined -> websocket_extensions(State, Req, Extensions, []); @@ -193,15 +189,15 @@ websocket_extensions(State, Req, [], []) -> websocket_extensions(State, Req, [], [<<", ">>|RespHeader]) -> {ok, State, cowboy_req:set_resp_header(<<"sec-websocket-extensions">>, lists:reverse(RespHeader), Req)}; %% For HTTP/2 we ARE on the controlling process and do NOT want to update the owner. -websocket_extensions(State=#state{extensions=Extensions}, Req=#{pid := Pid, version := Version}, +websocket_extensions(State=#state{opts=Opts, extensions=Extensions}, + Req=#{pid := Pid, version := Version}, [{<<"permessage-deflate">>, Params}|Tail], RespHeader) -> - %% @todo Make deflate options configurable. - Opts0 = #{level => best_compression, mem_level => 8, strategy => default}, - Opts = case Version of - 'HTTP/1.1' -> Opts0#{owner => Pid}; - _ -> Opts0 + DeflateOpts0 = maps:get(deflate_opts, Opts, #{}), + DeflateOpts = case Version of + 'HTTP/1.1' -> DeflateOpts0#{owner => Pid}; + _ -> DeflateOpts0 end, - try cow_ws:negotiate_permessage_deflate(Params, Extensions, Opts) of + try cow_ws:negotiate_permessage_deflate(Params, Extensions, DeflateOpts) of {ok, RespExt, Extensions2} -> websocket_extensions(State#state{extensions=Extensions2}, Req, Tail, [<<", ">>, RespExt|RespHeader]); @@ -210,15 +206,15 @@ websocket_extensions(State=#state{extensions=Extensions}, Req=#{pid := Pid, vers catch exit:{error, incompatible_zlib_version, _} -> websocket_extensions(State, Req, Tail, RespHeader) end; -websocket_extensions(State=#state{extensions=Extensions}, Req=#{pid := Pid, version := Version}, +websocket_extensions(State=#state{opts=Opts, extensions=Extensions}, + Req=#{pid := Pid, version := Version}, [{<<"x-webkit-deflate-frame">>, Params}|Tail], RespHeader) -> - %% @todo Make deflate options configurable. - Opts0 = #{level => best_compression, mem_level => 8, strategy => default}, - Opts = case Version of - 'HTTP/1.1' -> Opts0#{owner => Pid}; - _ -> Opts0 + DeflateOpts0 = maps:get(deflate_opts, Opts, #{}), + DeflateOpts = case Version of + 'HTTP/1.1' -> DeflateOpts0#{owner => Pid}; + _ -> DeflateOpts0 end, - try cow_ws:negotiate_x_webkit_deflate_frame(Params, Extensions, Opts) of + try cow_ws:negotiate_x_webkit_deflate_frame(Params, Extensions, DeflateOpts) of {ok, RespExt, Extensions2} -> websocket_extensions(State#state{extensions=Extensions2}, Req, Tail, [<<", ">>, RespExt|RespHeader]); @@ -317,13 +313,18 @@ before_loop(State=#state{socket=Socket, transport=Transport}, loop(State, HandlerState, ParseState). -spec loop_timeout(#state{}) -> #state{}. -loop_timeout(State=#state{timeout=infinity}) -> - State#state{timeout_ref=undefined}; -loop_timeout(State=#state{timeout=Timeout, timeout_ref=PrevRef}) -> - _ = case PrevRef of undefined -> ignore; PrevRef -> - erlang:cancel_timer(PrevRef) end, - TRef = erlang:start_timer(Timeout, self(), ?MODULE), - State#state{timeout_ref=TRef}. +loop_timeout(State=#state{opts=Opts, timeout_ref=PrevRef}) -> + _ = case PrevRef of + undefined -> ignore; + PrevRef -> erlang:cancel_timer(PrevRef) + end, + case maps:get(idle_timeout, Opts, 60000) of + infinity -> + State#state{timeout_ref=undefined}; + Timeout -> + TRef = erlang:start_timer(Timeout, self(), ?MODULE), + State#state{timeout_ref=TRef} + end. -spec loop(#state{}, any(), parse_state()) -> no_return(). loop(State=#state{parent=Parent, socket=Socket, messages=Messages, @@ -377,9 +378,9 @@ parse(State, HandlerState, PS=#ps_payload{buffer=Buffer}, Data) -> parse_payload(State, HandlerState, PS#ps_payload{buffer= <<>>}, <<Buffer/binary, Data/binary>>). -parse_header(State=#state{max_frame_size=MaxFrameSize, - frag_state=FragState, extensions=Extensions}, +parse_header(State=#state{opts=Opts, frag_state=FragState, extensions=Extensions}, HandlerState, ParseState=#ps_header{buffer=Data}) -> + MaxFrameSize = maps:get(max_frame_size, Opts, infinity), case cow_ws:parse_header(Data, Extensions, FragState) of %% All frames sent from the client to the server are masked. {_, _, _, _, undefined, _} -> @@ -423,10 +424,11 @@ parse_payload(State=#state{frag_state=FragState, utf8_state=Incomplete, extensio websocket_close(State, HandlerState, Error) end. -dispatch_frame(State=#state{max_frame_size=MaxFrameSize, frag_state=FragState, +dispatch_frame(State=#state{opts=Opts, frag_state=FragState, frag_buffer=SoFar, extensions=Extensions}, HandlerState, #ps_payload{type=Type0, unmasked=Payload0, close_code=CloseCode0}, RemainingData) -> + MaxFrameSize = maps:get(max_frame_size, Opts, infinity), case cow_ws:make_frame(Type0, Payload0, CloseCode0, FragState) of %% @todo Allow receiving fragments. {fragment, _, _, Payload} when byte_size(Payload) + byte_size(SoFar) > MaxFrameSize -> diff --git a/test/handlers/ws_deflate_opts_h.erl b/test/handlers/ws_deflate_opts_h.erl new file mode 100644 index 0000000..1c15efe --- /dev/null +++ b/test/handlers/ws_deflate_opts_h.erl @@ -0,0 +1,36 @@ +%% This module enables compression and returns deflate +%% options depending on the query string. + +-module(ws_deflate_opts_h). +-behavior(cowboy_websocket). + +-export([init/2]). +-export([websocket_handle/2]). +-export([websocket_info/2]). + +init(Req=#{qs := Qs}, State) -> + {Name, Value} = case Qs of + <<"server_context_takeover">> -> {server_context_takeover, takeover}; + <<"server_no_context_takeover">> -> {server_context_takeover, no_takeover}; + <<"client_context_takeover">> -> {client_context_takeover, takeover}; + <<"client_no_context_takeover">> -> {client_context_takeover, no_takeover}; + <<"server_max_window_bits">> -> {server_max_window_bits, 9}; + <<"client_max_window_bits">> -> {client_max_window_bits, 9}; + <<"level">> -> {level, best_speed}; + <<"mem_level">> -> {mem_level, 1}; + <<"strategy">> -> {strategy, rle} + end, + {cowboy_websocket, Req, State, #{ + compress => true, + deflate_opts => #{Name => Value} + }}. + +websocket_handle({text, Data}, State) -> + {reply, {text, Data}, State}; +websocket_handle({binary, Data}, State) -> + {reply, {binary, Data}, State}; +websocket_handle(_, State) -> + {ok, State}. + +websocket_info(_, State) -> + {ok, State}. diff --git a/test/ws_SUITE.erl b/test/ws_SUITE.erl index 284d571..af1be05 100644 --- a/test/ws_SUITE.erl +++ b/test/ws_SUITE.erl @@ -83,7 +83,8 @@ init_dispatch() -> {"/terminate", ws_terminate_h, []}, {"/ws_timeout_hibernate", ws_timeout_hibernate, []}, {"/ws_timeout_cancel", ws_timeout_cancel, []}, - {"/ws_max_frame_size", ws_max_frame_size, []} + {"/ws_max_frame_size", ws_max_frame_size, []}, + {"/ws_deflate_opts", ws_deflate_opts_h, []} ]} ]). @@ -231,6 +232,123 @@ do_ws_version(Socket) -> {error, closed} = gen_tcp:recv(Socket, 0, 6000), ok. +ws_deflate_opts_client_context_takeover(Config) -> + doc("Handler is configured with client context takeover enabled."), + {ok, _, Headers1} = do_handshake("/ws_deflate_opts?client_context_takeover", + "Sec-WebSocket-Extensions: permessage-deflate\r\n", Config), + {_, "permessage-deflate"} + = lists:keyfind("sec-websocket-extensions", 1, Headers1), + {ok, _, Headers2} = do_handshake("/ws_deflate_opts?client_context_takeover", + "Sec-WebSocket-Extensions: permessage-deflate; client_no_context_takeover\r\n", Config), + {_, "permessage-deflate; client_no_context_takeover"} + = lists:keyfind("sec-websocket-extensions", 1, Headers2), + ok. + +ws_deflate_opts_client_no_context_takeover(Config) -> + doc("Handler is configured with client context takeover disabled."), + {ok, _, Headers1} = do_handshake("/ws_deflate_opts?client_no_context_takeover", + "Sec-WebSocket-Extensions: permessage-deflate\r\n", Config), + {_, "permessage-deflate; client_no_context_takeover"} + = lists:keyfind("sec-websocket-extensions", 1, Headers1), + {ok, _, Headers2} = do_handshake("/ws_deflate_opts?client_no_context_takeover", + "Sec-WebSocket-Extensions: permessage-deflate; client_no_context_takeover\r\n", Config), + {_, "permessage-deflate; client_no_context_takeover"} + = lists:keyfind("sec-websocket-extensions", 1, Headers2), + ok. + +%% We must send client_max_window_bits to indicate we support it. +ws_deflate_opts_client_max_window_bits(Config) -> + doc("Handler is configured with client max window bits."), + {ok, _, Headers} = do_handshake("/ws_deflate_opts?client_max_window_bits", + "Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits\r\n", Config), + {_, "permessage-deflate; client_max_window_bits=9"} + = lists:keyfind("sec-websocket-extensions", 1, Headers), + ok. + +ws_deflate_opts_client_max_window_bits_override(Config) -> + doc("Handler is configured with client max window bits."), + {ok, _, Headers1} = do_handshake("/ws_deflate_opts?client_max_window_bits", + "Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits=8\r\n", Config), + {_, "permessage-deflate; client_max_window_bits=8"} + = lists:keyfind("sec-websocket-extensions", 1, Headers1), + {ok, _, Headers2} = do_handshake("/ws_deflate_opts?client_max_window_bits", + "Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits=12\r\n", Config), + {_, "permessage-deflate; client_max_window_bits=9"} + = lists:keyfind("sec-websocket-extensions", 1, Headers2), + ok. + +ws_deflate_opts_server_context_takeover(Config) -> + doc("Handler is configured with server context takeover enabled."), + {ok, _, Headers1} = do_handshake("/ws_deflate_opts?server_context_takeover", + "Sec-WebSocket-Extensions: permessage-deflate\r\n", Config), + {_, "permessage-deflate"} + = lists:keyfind("sec-websocket-extensions", 1, Headers1), + {ok, _, Headers2} = do_handshake("/ws_deflate_opts?server_context_takeover", + "Sec-WebSocket-Extensions: permessage-deflate; server_no_context_takeover\r\n", Config), + {_, "permessage-deflate; server_no_context_takeover"} + = lists:keyfind("sec-websocket-extensions", 1, Headers2), + ok. + +ws_deflate_opts_server_no_context_takeover(Config) -> + doc("Handler is configured with server context takeover disabled."), + {ok, _, Headers1} = do_handshake("/ws_deflate_opts?server_no_context_takeover", + "Sec-WebSocket-Extensions: permessage-deflate\r\n", Config), + {_, "permessage-deflate; server_no_context_takeover"} + = lists:keyfind("sec-websocket-extensions", 1, Headers1), + {ok, _, Headers2} = do_handshake("/ws_deflate_opts?server_no_context_takeover", + "Sec-WebSocket-Extensions: permessage-deflate; server_no_context_takeover\r\n", Config), + {_, "permessage-deflate; server_no_context_takeover"} + = lists:keyfind("sec-websocket-extensions", 1, Headers2), + ok. + +ws_deflate_opts_server_max_window_bits(Config) -> + doc("Handler is configured with server max window bits."), + {ok, _, Headers} = do_handshake("/ws_deflate_opts?server_max_window_bits", + "Sec-WebSocket-Extensions: permessage-deflate\r\n", Config), + {_, "permessage-deflate; server_max_window_bits=9"} + = lists:keyfind("sec-websocket-extensions", 1, Headers), + ok. + +ws_deflate_opts_server_max_window_bits_override(Config) -> + doc("Handler is configured with server max window bits."), + {ok, _, Headers1} = do_handshake("/ws_deflate_opts?server_max_window_bits", + "Sec-WebSocket-Extensions: permessage-deflate; server_max_window_bits=8\r\n", Config), + {_, "permessage-deflate; server_max_window_bits=8"} + = lists:keyfind("sec-websocket-extensions", 1, Headers1), + {ok, _, Headers2} = do_handshake("/ws_deflate_opts?server_max_window_bits", + "Sec-WebSocket-Extensions: permessage-deflate; server_max_window_bits=12\r\n", Config), + {_, "permessage-deflate; server_max_window_bits=9"} + = lists:keyfind("sec-websocket-extensions", 1, Headers2), + ok. + +ws_deflate_opts_zlevel(Config) -> + doc("Handler is configured with zlib level."), + do_ws_deflate_opts_z("/ws_deflate_opts?level", Config). + +ws_deflate_opts_zmemlevel(Config) -> + doc("Handler is configured with zlib mem_level."), + do_ws_deflate_opts_z("/ws_deflate_opts?mem_level", Config). + +ws_deflate_opts_zstrategy(Config) -> + doc("Handler is configured with zlib strategy."), + do_ws_deflate_opts_z("/ws_deflate_opts?strategy", Config). + +do_ws_deflate_opts_z(Path, Config) -> + {ok, Socket, Headers} = do_handshake(Path, + "Sec-WebSocket-Extensions: permessage-deflate\r\n", Config), + {_, "permessage-deflate"} = lists:keyfind("sec-websocket-extensions", 1, Headers), + %% Send and receive a compressed "Hello" frame. + Mask = 16#11223344, + CompressedHello = << 242, 72, 205, 201, 201, 7, 0 >>, + MaskedHello = do_mask(CompressedHello, Mask, <<>>), + ok = gen_tcp:send(Socket, << 1:1, 1:1, 0:2, 1:4, 1:1, 7:7, Mask:32, MaskedHello/binary >>), + {ok, << 1:1, 1:1, 0:2, 1:4, 0:1, 7:7, CompressedHello/binary >>} = gen_tcp:recv(Socket, 0, 6000), + %% Client-initiated close. + ok = gen_tcp:send(Socket, << 1:1, 0:3, 8:4, 1:1, 0:7, 0:32 >>), + {ok, << 1:1, 0:3, 8:4, 0:8 >>} = gen_tcp:recv(Socket, 0, 6000), + {error, closed} = gen_tcp:recv(Socket, 0, 6000), + ok. + ws_init_return_ok(Config) -> doc("Handler does nothing."), {ok, Socket, _} = do_handshake("/ws_init?ok", Config), |