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