1: -module(mod_websockets_SUITE). 2: -compile([export_all, nowarn_export_all]). 3: -include_lib("eunit/include/eunit.hrl"). 4: 5: -define(eq(E, I), ?assertEqual(E, I)). 6: -define(PORT, 5280). 7: -define(IP, {127, 0, 0, 1}). 8: -define(FAST_PING_RATE, 500). 9: -define(IDLE_TIMEOUT, 1200 * 2 + 300). 10: 11: -import(config_parser_helper, [config/2, default_config/1]). 12: 13: all() -> 14: [ping_test | subprotocol_header_tests() ++ timeout_tests()]. 15: 16: subprotocol_header_tests() -> 17: [agree_to_xmpp_subprotocol, 18: agree_to_xmpp_subprotocol_case_insensitive, 19: agree_to_xmpp_subprotocol_from_many, 20: do_not_agree_to_missing_subprotocol, 21: do_not_agree_to_other_subprotocol]. 22: 23: timeout_tests() -> 24: [connection_is_closed_after_idle_timeout, 25: client_ping_frame_resets_idle_timeout]. 26: 27: init_per_suite(C) -> 28: setup(), 29: C. 30: 31: end_per_suite(_) -> 32: teardown(), 33: ok. 34: 35: init_per_testcase(_, C) -> 36: C. 37: 38: end_per_testcase(_, C) -> 39: C. 40: 41: setup() -> 42: meck:unload(), 43: application:ensure_all_started(cowboy), 44: application:ensure_all_started(jid), 45: meck:new(supervisor, [unstick, passthrough, no_link]), 46: meck:new(gen_mod,[unstick, passthrough, no_link]), 47: %% Set ping rate 48: meck:expect(gen_mod,get_opt, fun(ping_rate, _, none) -> ?FAST_PING_RATE; 49: (A, B, C) -> meck:passthrough([A, B, C]) end), 50: meck:expect(supervisor, start_child, 51: fun(mongoose_listener_sup, _) -> {ok, self()}; 52: (A, B) -> meck:passthrough([A, B]) 53: end), 54: mongoose_config:set_opts(#{default_server_name => <<"localhost">>}), 55: %% Start websocket cowboy listening 56: Handlers = [config([listen, http, handlers, mod_bosh], 57: #{host => '_', path => "/http-bind"}), 58: config([listen, http, handlers, mod_websockets], 59: #{host => '_', path => "/ws-xmpp", 60: timeout => ?IDLE_TIMEOUT, ping_rate => ?FAST_PING_RATE})], 61: ejabberd_cowboy:start_listener(#{port => ?PORT, 62: ip_tuple => ?IP, 63: ip_address => "127.0.0.1", 64: ip_version => 4, 65: proto => tcp, 66: handlers => Handlers, 67: transport => default_config([listen, http, transport]), 68: protocol => default_config([listen, http, protocol])}). 69: 70: teardown() -> 71: meck:unload(), 72: cowboy:stop_listener(ejabberd_cowboy:ref({?PORT, ?IP, tcp})), 73: mongoose_config:erase_opts(), 74: application:stop(cowboy), 75: %% Do not stop jid, Erlang 21 does not like to reload nifs 76: ok. 77: 78: ping_test(_Config) -> 79: timer:sleep(500), 80: #{socket := Socket1} = ws_handshake(), 81: %% When 82: Resp = wait_for_ping(Socket1, 0, 5000), 83: %% then 84: ?eq(Resp, ok). 85: 86: connection_is_closed_after_idle_timeout(_Config) -> 87: #{socket := Socket} = ws_handshake(), 88: inet:setopts(Socket, [{active, true}]), 89: Closed = wait_for_close(Socket), 90: ?eq(Closed, ok). 91: 92: client_ping_frame_resets_idle_timeout(_Config) -> 93: #{socket := Socket} = ws_handshake(#{extra_headers => [<<"sec-websocket-protocol: xmpp\r\n">>]}), 94: Now = os:system_time(millisecond), 95: inet:setopts(Socket, [{active, true}]), 96: WaitBeforePingFrame = (?IDLE_TIMEOUT) div 2, 97: timer:sleep(WaitBeforePingFrame), 98: %%Masked ping frame 99: Ping = << 1:1, 0:3, 9:4, 1:1, 0:39 >>, 100: ok = gen_tcp:send(Socket, Ping), 101: Closed = wait_for_close(Socket), 102: ?eq(Closed, ok), 103: End = os:system_time(millisecond), 104: %%Below we check if the time difference between now and the moment 105: %%the WebSocket was established is bigger then the the ?IDLE_TIMEOUT plus initial wait time 106: %%This shows that the connection was not killed after the first ?IDLE_TIMEOUT 107: ?assert(End - Now > ?IDLE_TIMEOUT + WaitBeforePingFrame). 108: 109: wait_for_close(Socket) -> 110: receive 111: {tcp_closed, Socket} -> 112: ok 113: after ?IDLE_TIMEOUT + 500 -> 114: timeout 115: end. 116: 117: 118: ws_handshake() -> 119: ws_handshake(#{}). 120: 121: %% Client side 122: %% Gun is too high level for subprotocol_header_tests checks 123: ws_handshake(Opts) -> 124: Host = "localhost", 125: Port = ?PORT, 126: {ok, Socket} = gen_tcp:connect(Host, Port, [binary, {packet, raw}, 127: {active, false}]), 128: ok = gen_tcp:send(Socket, 129: ["GET /ws-xmpp HTTP/1.1\r\n" 130: "Host: localhost:5280\r\n" 131: "Connection: upgrade\r\n" 132: "Origin: http://localhost\r\n" 133: "Sec-WebSocket-Key: NT1P6NvEFQyDDKuTyEN+1Q==\r\n" 134: "Sec-WebSocket-Version: 13\r\n" 135: "Upgrade: websocket\r\n" 136: ++ maps:get(extra_headers, Opts, ""), 137: "\r\n"]), 138: {ok, Handshake} = gen_tcp:recv(Socket, 0, 5000), 139: Packet = erlang:decode_packet(http, Handshake, []), 140: {ok, {http_response, {1,1}, 101, "Switching Protocols"}, Rest} = Packet, 141: {Headers, _} = consume_headers(Rest, []), 142: InternalSocket = get_websocket(), 143: #{socket => Socket, internal_socket => InternalSocket, headers => Headers}. 144: 145: consume_headers(Data, Headers) -> 146: case erlang:decode_packet(httph, Data, []) of 147: {ok, http_eoh, Rest} -> 148: {lists:reverse(Headers), Rest}; 149: {ok, {http_header,_,Name,_,Value}, Rest} -> 150: consume_headers(Rest, [{Name, Value}|Headers]) 151: end. 152: 153: wait_for_ping(_, Try, _) when Try > 10 -> 154: {error, no_ping_packet}; 155: wait_for_ping(Socket, Try, Timeout) -> 156: {Reply, Content} = gen_tcp:recv(Socket, 0, Timeout), 157: case Reply of 158: error -> 159: {error, Content}; 160: ok -> 161: Ping = ws_rx_frame(<<"">>, 9), 162: case Content of 163: Ping -> 164: ok; 165: _ -> 166: wait_for_ping(Socket, Try + 1, Timeout) 167: end 168: end. 169: 170: %% Helpers 171: ws_rx_frame(Payload, Opcode) -> 172: Length = byte_size(Payload), 173: <<1:1, 0:3, Opcode:4, 0:1, Length:7, Payload/binary>>. 174: 175: get_websocket() -> 176: %% Assumption: there's only one ranch protocol process running and 177: %% it's the one which started due to our gen_tcp:connect in ws_handshake/1 178: [{cowboy_clear, Pid}] = get_ranch_connections(), 179: %% This is a record! See mod_websockets: #websocket{}. 180: {websocket, Pid, fake_peername, undefined}. 181: 182: get_child_by_mod(Sup, Mod) -> 183: Kids = supervisor:which_children(Sup), 184: case lists:keyfind([Mod], 4, Kids) of 185: false -> error(not_found, [Sup, Mod]); 186: {_, KidPid, _, _} -> KidPid 187: end. 188: 189: get_ranch_connections() -> 190: LSup = get_child_by_mod(ranch_sup, ranch_listener_sup), 191: CSup = get_child_by_mod(LSup, ranch_conns_sup_sup), 192: [{Mod, Pid} || {_, Sup, _, [ranch_conns_sup]} <- supervisor:which_children(CSup), 193: {_, Pid, _, [Mod]} <- supervisor:which_children(Sup)]. 194: 195: wait_for_no_ranch_connections(Times) -> 196: case get_ranch_connections() of 197: [] -> 198: ok; 199: _ when Times > 0 -> 200: timer:sleep(100), 201: wait_for_no_ranch_connections(Times - 1); 202: Connections -> 203: error(#{reason => wait_for_no_ranch_connections_failed, 204: connections => Connections}) 205: end. 206: 207: %% --------------------------------------------------------------------- 208: %% subprotocol_header_tests test functions 209: %% --------------------------------------------------------------------- 210: 211: %% From RFC 6455: 212: %% The |Sec-WebSocket-Protocol| header field is used in the WebSocket 213: %% opening handshake. It is sent from the client to the server and back 214: %% from the server to the client to confirm the subprotocol of the 215: %% connection. 216: agree_to_xmpp_subprotocol(_) -> 217: check_subprotocol("Proper client behaviour", ["xmpp"], "xmpp"). 218: 219: agree_to_xmpp_subprotocol_case_insensitive(_) -> 220: %% The value must conform to the requirements 221: %% given in item 10 of Section 4.1 of this specification -- namely, 222: %% the value must be a token as defined by RFC 2616 [RFC2616]. 223: %% ... 224: %% 10. The request MAY include a header field with the name 225: %% |Sec-WebSocket-Protocol|. If present, this value indicates one 226: %% or more comma-separated subprotocol the client wishes to speak, 227: %% ordered by preference. The elements that comprise this value 228: %% MUST be non-empty strings with characters in the range U+0021 to 229: %% U+007E not including separator characters as defined in 230: %% [RFC2616] and MUST all be unique strings. The ABNF for the 231: %% value of this header field is 1#token, where the definitions of 232: %% constructs and rules are as given in [RFC2616]. 233: %% ... 234: check_subprotocol("Case insensitive", ["XMPP"], "XMPP"). 235: 236: %% From RFC 6455: 237: %% The |Sec-WebSocket-Protocol| header field MAY appear multiple times 238: %% in an HTTP request (which is logically the same as a single 239: %% |Sec-WebSocket-Protocol| header field that contains all values). 240: %% However, the |Sec-WebSocket-Protocol| header field MUST NOT appear 241: %% more than once in an HTTP response. 242: agree_to_xmpp_subprotocol_from_many(_) -> 243: check_subprotocol("Two protocols in one header", ["xmpp, other"], "xmpp"), 244: check_subprotocol("Two protocols in one header", ["other, xmpp"], "xmpp"), 245: check_subprotocol("Two protocols in two headers", ["other", "xmpp"], "xmpp"). 246: 247: %% Do not set a Sec-Websocket-Protocol header in response if it's missing in a request. 248: %% 249: %% From RFC 6455: 250: %% if the server does not wish to agree to one of the suggested 251: %% subprotocols, it MUST NOT send back a |Sec-WebSocket-Protocol| 252: %% header field in its response. 253: do_not_agree_to_missing_subprotocol(_) -> 254: check_subprotocol("Subprotocol header is missing", [], undefined). 255: 256: %% Do not set a Sec-Websocket-Protocol header in response if it's provided, but not xmpp. 257: do_not_agree_to_other_subprotocol(_) -> 258: check_subprotocol("Subprotocol is not xmpp", ["other"], undefined). 259: 260: 261: %% --------------------------------------------------------------------- 262: %% subprotocol_header_tests helper functions 263: %% --------------------------------------------------------------------- 264: 265: check_subprotocol(Comment, ProtoList, ExpectedProtocol) -> 266: ReqHeaders = lists:append(["Sec-Websocket-Protocol: " ++ Proto ++ "\r\n" || Proto <- ProtoList]), 267: Info = #{reason => check_subprotocol_failed, 268: comment => Comment, 269: expected_protocol => ExpectedProtocol, 270: request_headers => ReqHeaders}, 271: #{headers := RespHeaders, socket := Socket} = ws_handshake(#{extra_headers => ReqHeaders}), 272: %% get_websocket/0 does not support more than one open connection 273: gen_tcp:close(Socket), 274: wait_for_no_ranch_connections(10), 275: RespProtocol = proplists:get_value("Sec-Websocket-Protocol", RespHeaders), 276: case RespProtocol of 277: ExpectedProtocol -> 278: ok; 279: _ -> 280: Info2 = Info#{response_headers => RespHeaders, 281: response_protocol => RespProtocol}, 282: ct:fail(Info2) 283: end.