./ct_report/coverage/mod_bosh.COVER.html

1 %%%===================================================================
2 %%% @copyright (C) 2013, Erlang Solutions Ltd.
3 %%% @doc Cowboy based BOSH support for MongooseIM
4 %%%
5 %%% @end
6 %%%===================================================================
7 -module(mod_bosh).
8 -behaviour(gen_mod).
9 -behaviour(mongoose_module_metrics).
10 %% cowboy_loop is a long polling handler
11 -behaviour(cowboy_loop).
12
13 -xep([{xep, 206}, {version, "1.4"}]).
14 -xep([{xep, 124}, {version, "1.11.2"}]).
15
16 %% gen_mod callbacks
17 -export([start/2,
18 stop/1,
19 config_spec/0,
20 supported_features/0]).
21
22 %% cowboy_loop_handler callbacks
23 -export([init/2,
24 info/3,
25 terminate/3]).
26
27 %% Hooks callbacks
28 -export([node_cleanup/3]).
29
30 %% For testing and debugging
31 -export([get_session_socket/1, store_session/2, instrumentation/0]).
32
33 -export([config_metrics/1]).
34
35 -ignore_xref([get_session_socket/1, store_session/2, instrumentation/0]).
36
37 -include("mongoose.hrl").
38 -include("jlib.hrl").
39 -include_lib("exml/include/exml_stream.hrl").
40 -include("mod_bosh.hrl").
41 -include("mongoose_config_spec.hrl").
42
43 -define(DEFAULT_MAX_AGE, 1728000). %% 20 days in seconds
44 -define(DEFAULT_ALLOW_ORIGIN, <<"*">>).
45
46 -export_type([session/0,
47 sid/0,
48 event_type/0,
49 socket/0
50 ]).
51
52 -type socket() :: #bosh_socket{}.
53 -type session() :: #bosh_session{
54 sid :: mod_bosh:sid(),
55 socket :: pid()
56 }.
57 -type sid() :: binary().
58 -type event_type() :: streamstart
59 | restart
60 | normal
61 | pause
62 | streamend.
63
64 -type headers_list() :: [{binary(), binary()}].
65
66 %% Request State
67 -record(rstate, {req_sid, opts}).
68 -type rstate() :: #rstate{}.
69 -type req() :: cowboy_req:req().
70
71 -type info() :: accept_options
72 | accept_get
73 | item_not_found
74 | no_body
75 | policy_violation
76 | {bosh_reply, exml:element()}
77 | {close, _}
78 | {wrong_method, _}.
79
80 %%--------------------------------------------------------------------
81 %% gen_mod callbacks
82 %%--------------------------------------------------------------------
83
84 -spec start(mongooseim:host_type(), gen_mod:module_opts()) -> ok.
85 start(_HostType, Opts) ->
86 377 case mod_bosh_socket:is_supervisor_started() of
87 true ->
88 279 ok; % There is only one backend implementation (mnesia), so it is started globally
89 false ->
90 98 mod_bosh_backend:start(Opts),
91 98 {ok, _Pid} = mod_bosh_socket:start_supervisor(),
92 98 gen_hook:add_handlers(hooks()),
93 % Because mod_bosh acts more like a service, than a module, instrumentation has to be
94 % set up manually, only once.
95 98 mongoose_instrument:set_up(instrumentation())
96 end.
97
98 -spec stop(mongooseim:host_type()) -> ok.
99 stop(_HostType) ->
100 377 mod_bosh_socket:stop_supervisor(),
101 377 mongoose_instrument:tear_down(instrumentation()),
102 377 gen_hook:delete_handlers(hooks()),
103 377 ok.
104
105 -spec hooks() -> gen_hook:hook_list().
106 hooks() ->
107 475 [{node_cleanup, global, fun ?MODULE:node_cleanup/3, #{}, 50}].
108
109 -spec instrumentation() -> [mongoose_instrument:spec()].
110 instrumentation() ->
111 478 [{mod_bosh_data_sent, #{},
112 #{metrics => #{byte_size => spiral}}},
113 {mod_bosh_data_received, #{},
114 #{metrics => #{byte_size => spiral}}}].
115
116 -spec config_spec() -> mongoose_config_spec:config_section().
117 config_spec() ->
118 186 #section{items = #{<<"backend">> => #option{type = atom,
119 validate = {module, mod_bosh}},
120 <<"inactivity">> => #option{type = int_or_infinity,
121 validate = positive},
122 <<"max_wait">> => #option{type = int_or_infinity,
123 validate = positive},
124 <<"server_acks">> => #option{type = boolean},
125 <<"max_pause">> => #option{type = integer,
126 validate = positive}
127 },
128 defaults = #{<<"backend">> => mnesia,
129 <<"inactivity">> => 30, % seconds
130 <<"max_wait">> => infinity, % seconds
131 <<"server_acks">> => false,
132 <<"max_pause">> => 120} % seconds
133 }.
134
135 -spec supported_features() -> [atom()].
136 supported_features() ->
137 193 [dynamic_domains].
138
139 %%--------------------------------------------------------------------
140 %% Hooks handlers
141 %%--------------------------------------------------------------------
142
143 -spec node_cleanup(Acc, Params, Extra) -> {ok, Acc} when
144 Acc :: map(),
145 Params :: #{node := node()},
146 Extra :: gen_hook:extra().
147 node_cleanup(Acc, #{node := Node}, _) ->
148 9 Res = mod_bosh_backend:node_cleanup(Node),
149 9 {ok, maps:put(?MODULE, Res, Acc)}.
150
151 %%--------------------------------------------------------------------
152 %% cowboy_loop_handler callbacks
153 %%--------------------------------------------------------------------
154
155 -spec init(req(), mongoose_http_handler:options()) -> {cowboy_loop, req(), rstate()}.
156 init(Req, Opts) ->
157 2029 ?LOG_DEBUG(#{what => bosh_init, req => Req}),
158 2029 Msg = init_msg(Req),
159 2029 self() ! Msg,
160 %% Upgrade to cowboy_loop behaviour to enable long polling
161 2029 {cowboy_loop, Req, #rstate{opts = Opts}}.
162
163
164 %% ok return keep the handler looping.
165 %% stop handler is used to reply to the client.
166 -spec info(info(), req(), rstate()) -> {ok, req(), _} | {stop, req(), _}.
167 info(accept_options, Req, State) ->
168 2 Origin = cowboy_req:header(<<"origin">>, Req),
169 2 Headers = ac_all(Origin),
170 2 ?LOG_DEBUG(#{what => bosh_accept_options, headers => Headers,
171 2 text => <<"Handle OPTIONS request in Bosh">>}),
172 2 Req1 = cowboy_reply(200, Headers, <<>>, Req),
173 2 {stop, Req1, State};
174 info(accept_get, Req, State) ->
175 2 Headers = [content_type(),
176 ac_allow_methods(),
177 ac_allow_headers(),
178 ac_max_age()],
179 2 Body = <<"MongooseIM bosh endpoint">>,
180 2 Req1 = cowboy_reply(200, Headers, Body, Req),
181 2 {stop, Req1, State};
182 info(forward_body, Req, S) ->
183 2021 {ok, Body, Req1} = cowboy_req:read_body(Req),
184 %% TODO: the parser should be stored per session,
185 %% but the session is identified inside the to-be-parsed element
186 2021 {ok, BodyElem} = exml:parse(Body),
187 2021 Sid = exml_query:attr(BodyElem, <<"sid">>, <<"missing">>),
188 2021 ?LOG_DEBUG(#{what => bosh_receive, sid => Sid, request_body => Body}),
189 %% Remember req_sid, so it can be used to print a debug message in bosh_reply
190 2021 forward_body(Req1, BodyElem, S#rstate{req_sid = Sid});
191 info({bosh_reply, El}, Req, S) ->
192 2010 BEl = exml:to_binary(El),
193 %% 'mod_bosh_data_sent' metric includes 'body' wrapping elements and resending attempts
194 2010 mongoose_instrument:execute(mod_bosh_data_sent, #{}, #{byte_size => byte_size(BEl)}),
195 2010 ?LOG_DEBUG(#{what => bosh_send, req_sid => S#rstate.req_sid, reply_body => BEl,
196 2010 sid => exml_query:attr(El, <<"sid">>, <<"missing">>)}),
197 2010 Headers = bosh_reply_headers(),
198 2010 Req1 = cowboy_reply(200, Headers, BEl, Req),
199 2010 {stop, Req1, S};
200
201 info({close, Sid}, Req, S) ->
202
:-(
?LOG_DEBUG(#{what => bosh_close, sid => Sid}),
203
:-(
Req1 = cowboy_reply(200, [], <<>>, Req),
204
:-(
{stop, Req1, S};
205 info(no_body, Req, State) ->
206 2 ?LOG_DEBUG(#{what => bosh_stop, reason => missing_request_body, req => Req}),
207 2 Req1 = no_body_error(Req),
208 2 {stop, Req1, State};
209 info({wrong_method, Method}, Req, State) ->
210 2 ?LOG_DEBUG(#{what => bosh_stop, reason => wrong_request_method,
211 2 method => Method, req => Req}),
212 2 Req1 = method_not_allowed_error(Req),
213 2 {stop, Req1, State};
214 info(item_not_found, Req, S) ->
215 4 Req1 = terminal_condition(<<"item-not-found">>, Req),
216 4 {stop, Req1, S};
217 info(policy_violation, Req, S) ->
218 2 Req1 = terminal_condition(<<"policy-violation">>, Req),
219 2 {stop, Req1, S}.
220
221
222 terminate(_Reason, _Req, _State) ->
223 2028 ?LOG_DEBUG(#{what => bosh_terminate}),
224 2028 ok.
225
226 %%--------------------------------------------------------------------
227 %% Callbacks implementation
228 %%--------------------------------------------------------------------
229
230 init_msg(Req) ->
231 2029 Method = cowboy_req:method(Req),
232 2029 case Method of
233 <<"OPTIONS">> ->
234 2 accept_options;
235 <<"POST">> ->
236 2023 case cowboy_req:has_body(Req) of
237 true ->
238 2021 forward_body;
239 false ->
240 2 no_body
241 end;
242 <<"GET">> ->
243 2 accept_get;
244 _ ->
245 2 {wrong_method, Method}
246 end.
247
248 -spec to_event_type(exml:element()) -> event_type().
249 to_event_type(Body) ->
250 %% Order of checks is important:
251 %% stream restart has got sid attribute,
252 %% so check for it at the end.
253 2021 check_event_type_streamend(Body).
254
255 check_event_type_streamend(Body) ->
256 2021 case exml_query:attr(Body, <<"type">>) of
257 <<"terminate">> ->
258 118 streamend;
259 _ ->
260 1903 check_event_type_restart(Body)
261 end.
262
263 check_event_type_restart(Body) ->
264 1903 case exml_query:attr(Body, <<"xmpp:restart">>) of
265 <<"true">> ->
266 121 restart;
267 _ ->
268 1782 check_event_type_pause(Body)
269 end.
270
271 check_event_type_pause(Body) ->
272 1782 case exml_query:attr(Body, <<"pause">>) of
273 undefined ->
274 1779 check_event_type_streamstrart(Body);
275 _ ->
276 3 pause
277 end.
278
279 check_event_type_streamstrart(Body) ->
280 1779 case exml_query:attr(Body, <<"sid">>) of
281 undefined ->
282 128 streamstart;
283 _ ->
284 1651 normal
285 end.
286
287 -spec forward_body(req(), exml:element(), rstate())
288 -> {ok, req(), rstate()} | {stop, req(), rstate()}.
289 forward_body(Req, #xmlel{} = Body, #rstate{opts = Opts} = S) ->
290 2021 Type = to_event_type(Body),
291 2021 case Type of
292 streamstart ->
293 128 {SessionStarted, Req1} = maybe_start_session(Req, Body, Opts),
294 128 case SessionStarted of
295 true ->
296 125 {ok, Req1, S};
297 false ->
298 3 {stop, Req1, S}
299 end;
300 _ ->
301 1893 Sid = exml_query:attr(Body, <<"sid">>),
302 1893 case get_session_socket(Sid) of
303 {ok, Socket} ->
304 %% Forward request from a client to c2s process
305 1892 handle_request(Socket, Type, Body),
306 1892 {ok, Req, S};
307 {error, item_not_found} ->
308 1 ?LOG_WARNING(#{what => bosh_stop, reason => session_not_found,
309
:-(
sid => Sid}),
310 1 Req1 = terminal_condition(<<"item-not-found">>, Req),
311 1 {stop, Req1, S}
312 end
313 end.
314
315
316 -spec handle_request(pid(), event_type(), exml:element()) -> ok.
317 handle_request(Socket, EventType, Body) ->
318 %% 'mod_bosh_data_received' metric includes 'body' wrapping elements
319 2017 mongoose_instrument:execute(mod_bosh_data_received, #{}, #{byte_size => exml:xml_size(Body)}),
320 2017 mod_bosh_socket:handle_request(Socket, {EventType, self(), Body}).
321
322
323 -spec get_session_socket(mod_bosh:sid()) -> {ok, pid()} | {error, item_not_found}.
324 get_session_socket(Sid) ->
325 1893 case mod_bosh_backend:get_session(Sid) of
326 [BS] ->
327 1892 {ok, BS#bosh_session.socket};
328 [] ->
329 1 {error, item_not_found}
330 end.
331
332
333 -spec maybe_start_session(req(), exml:element(), map()) ->
334 {SessionStarted :: boolean(), req()}.
335 maybe_start_session(Req, Body, Opts) ->
336 128 Domain = exml_query:attr(Body, <<"to">>),
337 128 case mongoose_domain_api:get_domain_host_type(Domain) of
338 {ok, HostType} ->
339 128 case gen_mod:is_loaded(HostType, ?MODULE) of
340 true ->
341 127 maybe_start_session_on_known_host(HostType, Req, Body, Opts);
342 false ->
343 1 {false, terminal_condition(<<"host-unknown">>, Req)}
344 end;
345 {error, not_found} ->
346
:-(
{false, terminal_condition(<<"host-unknown">>, Req)}
347 end.
348
349 -spec maybe_start_session_on_known_host(mongooseim:host_type(), req(), exml:element(), map()) ->
350 {SessionStarted :: boolean(), req()}.
351 maybe_start_session_on_known_host(HostType, Req, Body, Opts) ->
352 127 try
353 127 maybe_start_session_on_known_host_unsafe(HostType, Req, Body, Opts)
354 catch
355 error:Reason:Stacktrace ->
356 %% It's here because something catch-y was here before
357 2 ?LOG_ERROR(#{what => bosh_stop, issue => undefined_condition,
358
:-(
reason => Reason, stacktrace => Stacktrace}),
359 2 Req1 = terminal_condition(<<"undefined-condition">>, [], Req),
360 2 {false, Req1}
361 end.
362
363 -spec maybe_start_session_on_known_host_unsafe(mongooseim:host_type(), req(), exml:element(), map()) ->
364 {SessionStarted :: boolean(), req()}.
365 maybe_start_session_on_known_host_unsafe(HostType, Req, Body, Opts) ->
366 %% Version isn't checked as it would be meaningless when supporting
367 %% only a subset of the specification.
368 127 {ok, NewBody} = set_max_hold(Body),
369 125 Peer = cowboy_req:peer(Req),
370 125 PeerCert = cowboy_req:cert(Req),
371 125 start_session(HostType, Peer, PeerCert, NewBody, Opts),
372 125 {true, Req}.
373
374 -spec start_session(mongooseim:host_type(), mongoose_transport:peer(),
375 binary() | undefined, exml:element(), map()) -> any().
376 start_session(HostType, Peer, PeerCert, Body, Opts) ->
377 125 Sid = make_sid(),
378 125 {ok, Socket} = mod_bosh_socket:start(HostType, Sid, Peer, PeerCert, Opts),
379 125 store_session(Sid, Socket),
380 125 handle_request(Socket, streamstart, Body),
381 125 ?LOG_DEBUG(#{what => bosh_start_session, sid => Sid}).
382
383 -spec store_session(Sid :: sid(), Socket :: pid()) -> any().
384 store_session(Sid, Socket) ->
385 125 mod_bosh_backend:create_session(#bosh_session{sid = Sid, socket = Socket}).
386
387 %% MUST be unique and unpredictable
388 %% https://xmpp.org/extensions/xep-0124.html#security-sidrid
389 %% Also, CETS requires to use node as a part of the key
390 %% (but if the key is always random CETS is happy with that too)
391 -spec make_sid() -> binary().
392 make_sid() ->
393 125 base16:encode(crypto:strong_rand_bytes(20)).
394
395 %%--------------------------------------------------------------------
396 %% HTTP errors
397 %%--------------------------------------------------------------------
398
399 -spec no_body_error(cowboy_req:req()) -> cowboy_req:req().
400 no_body_error(Req) ->
401 2 cowboy_reply(400, ac_all(?DEFAULT_ALLOW_ORIGIN),
402 <<"Missing request body">>, Req).
403
404
405 -spec method_not_allowed_error(cowboy_req:req()) -> cowboy_req:req().
406 method_not_allowed_error(Req) ->
407 2 cowboy_reply(405, ac_all(?DEFAULT_ALLOW_ORIGIN),
408 <<"Use POST request method">>, Req).
409
410 %%--------------------------------------------------------------------
411 %% BOSH Terminal Binding Error Conditions
412 %%--------------------------------------------------------------------
413
414 -spec terminal_condition(binary(), cowboy_req:req()) -> cowboy_req:req().
415 terminal_condition(Condition, Req) ->
416 8 terminal_condition(Condition, [], Req).
417
418
419 -spec terminal_condition(binary(), [exml:element()], cowboy_req:req())
420 -> cowboy_req:req().
421 terminal_condition(Condition, Details, Req) ->
422 10 Body = terminal_condition_body(Condition, Details),
423 10 Headers = [content_type()] ++ ac_all(?DEFAULT_ALLOW_ORIGIN),
424 10 cowboy_reply(200, Headers, Body, Req).
425
426
427 -spec terminal_condition_body(binary(), [exml:element()]) -> binary().
428 terminal_condition_body(Condition, Children) ->
429 10 exml:to_binary(#xmlel{name = <<"body">>,
430 attrs = [{<<"type">>, <<"terminate">>},
431 {<<"condition">>, Condition},
432 {<<"xmlns">>, ?NS_HTTPBIND}],
433 children = Children}).
434
435 %%--------------------------------------------------------------------
436 %% Helpers
437 %%--------------------------------------------------------------------
438
439 content_type() ->
440 2022 {<<"content-type">>, <<"text/xml; charset=utf8">>}.
441
442 ac_allow_origin(Origin) ->
443 2026 {<<"access-control-allow-origin">>, Origin}.
444
445 ac_allow_methods() ->
446 2028 {<<"access-control-allow-methods">>, <<"POST, OPTIONS, GET">>}.
447
448 ac_allow_headers() ->
449 2028 {<<"access-control-allow-headers">>, <<"content-type">>}.
450
451 ac_max_age() ->
452 2028 {<<"access-control-max-age">>, integer_to_binary(?DEFAULT_MAX_AGE)}.
453
454
455 -spec ac_all('undefined' | binary()) -> headers_list().
456 ac_all(Origin) ->
457 16 [ac_allow_origin(Origin),
458 ac_allow_methods(),
459 ac_allow_headers(),
460 ac_max_age()].
461
462 -spec bosh_reply_headers() -> headers_list().
463 bosh_reply_headers() ->
464 2010 [content_type(),
465 ac_allow_origin(?DEFAULT_ALLOW_ORIGIN),
466 ac_allow_methods(),
467 ac_allow_headers(),
468 ac_max_age()].
469
470 set_max_hold(Body) ->
471 127 HoldBin = exml_query:attr(Body, <<"hold">>),
472 127 ClientHold = binary_to_integer(HoldBin),
473 127 maybe_set_max_hold(ClientHold, Body).
474
475
476 maybe_set_max_hold(1, Body) ->
477 123 {ok, Body};
478 maybe_set_max_hold(ClientHold, #xmlel{attrs = Attrs} = Body) when ClientHold > 1 ->
479 2 NewAttrs = lists:keyreplace(<<"hold">>, 1, Attrs, {<<"hold">>, <<"1">>}),
480 2 {ok, Body#xmlel{attrs = NewAttrs}};
481 maybe_set_max_hold(_, _) ->
482 2 {error, invalid_hold}.
483
484 -spec cowboy_reply(non_neg_integer(), headers_list(), binary(), req()) -> req().
485 cowboy_reply(Code, Headers, Body, Req) when is_list(Headers) ->
486 2028 cowboy_req:reply(Code, maps:from_list(Headers), Body, Req).
487
488 -spec config_metrics(mongooseim:host_type()) -> [{gen_mod:opt_key(), gen_mod:opt_value()}].
489 config_metrics(HostType) ->
490 252 mongoose_module_metrics:opts_for_module(HostType, ?MODULE, [backend]).
Line Hits Source