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