Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow custom HTTP headers in BOSH and WebSockets #2907

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions rel/files/mongooseim.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -122,14 +122,20 @@
{transport_options, [{max_connections, 1024}, {num_acceptors, 10}]},
{modules, [

{"_", "/http-bind", mod_bosh},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs to be rewritten for TOML

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any thoughts on how you want it written for TOML? The precedent in mod_revproxy is tuple list of headers. I cannot tell if you're gonna move that mod to TOML or drop it.

Since Cowboy natively uses maps HTTP headers, I'd like to map the headers as a map in the TOML. Will that work, even if I stray from the extra headers convention in mod_revproxy?

Copy link
Member

@chrzaszcz chrzaszcz Nov 27, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The whole mod_revproxy functionality is deprecated and will be dropped in the next release unless a new demand appears (which is unlikely). Custom headers would need to be added to TOML as a table (which is parsed as a map by tomerl). However, the whole TOML parser is being rewritten at the moment and a part of this code would need to be rewritten, unless you merge this PR to the toml-config branch.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll wait until #2929 is merged. I don't think it'd make sense to pollute that branch with this change, which is more functionality than config. I also plan on adding some default security headers like:

{ws_https_custom_headers, "[[listen.http.handlers.mod_websockets.custom_headers]]
  strict-transport-security: "max-age=31536000 ; includeSubDomains"
"}.

to the HTTPS endpoints as part of the standard as part of the standard vars-tool.config.in.

{"_", "/http-bind", mod_bosh, [
%% Uncomment to enable HTTP custom headers. Header names must be
%% lowercase.
% {custom_headers, [{"access-control-request-origin", "*"}]}
]},
{"_", "/ws-xmpp", mod_websockets, [{ejabberd_service, [
{access, all},
{shaper_rule, fast},
{password, "secret"}]}
%% Uncomment to enable connection dropping or/and server-side pings
%{timeout, 600000}, {ping_rate, 2000}
]}
%% Uncomment to enable connection dropping, server-side pings
%% and/or custom HTTP headers. Header names must be lowercase.
% {timeout, 600000}, {ping_rate, 60000},
% {custom_headers, [{"access-control-Request-origin", "*"}]}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I gotta lowercase this R

]}
%% Uncomment to serve static files
%{"_", "/static/[...]", cowboy_static,
% {dir, "/var/www", [{mimetypes, cow_mimetypes, all}]}
Expand Down
71 changes: 37 additions & 34 deletions src/mod_bosh.erl
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@
-type headers_list() :: [{binary(), binary()}].

%% Request State
-record(rstate, {req_sid}).
-record(rstate, {req_sid :: undefined | sid(),
custom_headers :: [{binary(), binary()}]}).
-type rstate() :: #rstate{}.
-type req() :: cowboy_req:req().

Expand Down Expand Up @@ -160,12 +161,13 @@ node_cleanup(Acc, Node) ->

-type option() :: {atom(), any()}.
-spec init(req(), _Opts :: [option()]) -> {cowboy_loop, req(), rstate()}.
init(Req, _Opts) ->
init(Req, Opts) ->
?LOG_DEBUG(#{what => bosh_init, req => Req}),
Msg = init_msg(Req),
self() ! Msg,
CustomHeaders = gen_mod:get_opt(custom_headers, Opts, []),
%% Upgrade to cowboy_loop behaviour to enable long polling
{cowboy_loop, Req, #rstate{}}.
{cowboy_loop, Req, #rstate{custom_headers = CustomHeaders}}.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can use cowboy_req:set_resp_headers here instead of passing State everywhere.



%% ok return keep the handler looping.
Expand All @@ -176,15 +178,15 @@ info(accept_options, Req, State) ->
Headers = ac_all(Origin),
?LOG_DEBUG(#{what => bosh_accept_options, headers => Headers,
text => <<"Handle OPTIONS request in Bosh">>}),
Req1 = cowboy_reply(200, Headers, <<>>, Req),
Req1 = cowboy_reply(200, Headers, <<>>, Req, State),
{stop, Req1, State};
info(accept_get, Req, State) ->
Headers = [content_type(),
ac_allow_methods(),
ac_allow_headers(),
ac_max_age()],
Body = <<"MongooseIM bosh endpoint">>,
Req1 = cowboy_reply(200, Headers, Body, Req),
Req1 = cowboy_reply(200, Headers, Body, Req, State),
{stop, Req1, State};
info(forward_body, Req, S) ->
{ok, Body, Req1} = cowboy_req:read_body(Req),
Expand All @@ -200,27 +202,27 @@ info({bosh_reply, El}, Req, S) ->
?LOG_DEBUG(#{what => bosh_send, req_sid => S#rstate.req_sid, reply_body => BEl,
sid => exml_query:attr(El, <<"sid">>, <<"missing">>)}),
Headers = bosh_reply_headers(),
Req1 = cowboy_reply(200, Headers, BEl, Req),
Req1 = cowboy_reply(200, Headers, BEl, Req, S),
{stop, Req1, S};

info({close, Sid}, Req, S) ->
?LOG_DEBUG(#{what => bosh_close, sid => Sid}),
Req1 = cowboy_reply(200, [], <<>>, Req),
Req1 = cowboy_reply(200, [], <<>>, Req, S),
{stop, Req1, S};
info(no_body, Req, State) ->
?LOG_DEBUG(#{what => bosh_stop, reason => missing_request_body, req => Req}),
Req1 = no_body_error(Req),
Req1 = no_body_error(Req, State),
{stop, Req1, State};
info({wrong_method, Method}, Req, State) ->
?LOG_DEBUG(#{what => bosh_stop, reason => wrong_request_method,
method => Method, req => Req}),
Req1 = method_not_allowed_error(Req),
Req1 = method_not_allowed_error(Req, State),
{stop, Req1, State};
info(item_not_found, Req, S) ->
Req1 = terminal_condition(<<"item-not-found">>, Req),
Req1 = terminal_condition(<<"item-not-found">>, Req, S),
{stop, Req1, S};
info(policy_violation, Req, S) ->
Req1 = terminal_condition(<<"policy-violation">>, Req),
Req1 = terminal_condition(<<"policy-violation">>, Req, S),
{stop, Req1, S}.


Expand Down Expand Up @@ -300,7 +302,7 @@ forward_body(Req, #xmlel{} = Body, S) ->
Type = to_event_type(Body),
case Type of
streamstart ->
{SessionStarted, Req1} = maybe_start_session(Req, Body),
{SessionStarted, Req1} = maybe_start_session(Req, Body, S),
case SessionStarted of
true ->
{ok, Req1, S};
Expand All @@ -317,7 +319,7 @@ forward_body(Req, #xmlel{} = Body, S) ->
{error, item_not_found} ->
?LOG_WARNING(#{what => bosh_stop, reason => session_not_found,
sid => Sid}),
Req1 = terminal_condition(<<"item-not-found">>, Req),
Req1 = terminal_condition(<<"item-not-found">>, Req, S),
{stop, Req1, S}
end
end.
Expand All @@ -338,27 +340,27 @@ get_session_socket(Sid) ->
end.


-spec maybe_start_session(req(), exml:element()) ->
-spec maybe_start_session(req(), exml:element(), rstate()) ->
{SessionStarted :: boolean(), req()}.
maybe_start_session(Req, Body) ->
maybe_start_session(Req, Body, State) ->
Host = exml_query:attr(Body, <<"to">>),
case is_known_host(Host) of
true ->
maybe_start_session_on_known_host(Req, Body);
maybe_start_session_on_known_host(Req, Body, State);
false ->
Req1 = terminal_condition(<<"host-unknown">>, Req),
Req1 = terminal_condition(<<"host-unknown">>, Req, State),
{false, Req1}
end.

maybe_start_session_on_known_host(Req, Body) ->
maybe_start_session_on_known_host(Req, Body, State) ->
try
maybe_start_session_on_known_host_unsafe(Req, Body)
catch
error:Reason ->
%% It's here because something catch-y was here before
?LOG_ERROR(#{what => bosh_stop, issue => undefined_condition,
reason => Reason}),
Req1 = terminal_condition(<<"undefined-condition">>, [], Req),
Req1 = terminal_condition(<<"undefined-condition">>, [], Req, State),
{false, Req1}
end.

Expand Down Expand Up @@ -396,32 +398,32 @@ make_sid() ->
%% HTTP errors
%%--------------------------------------------------------------------

-spec no_body_error(cowboy_req:req()) -> cowboy_req:req().
no_body_error(Req) ->
-spec no_body_error(cowboy_req:req(), rstate()) -> cowboy_req:req().
no_body_error(Req, State) ->
cowboy_reply(400, ac_all(?DEFAULT_ALLOW_ORIGIN),
<<"Missing request body">>, Req).
<<"Missing request body">>, Req, State).


-spec method_not_allowed_error(cowboy_req:req()) -> cowboy_req:req().
method_not_allowed_error(Req) ->
-spec method_not_allowed_error(cowboy_req:req(), rstate()) -> cowboy_req:req().
method_not_allowed_error(Req, State) ->
cowboy_reply(405, ac_all(?DEFAULT_ALLOW_ORIGIN),
<<"Use POST request method">>, Req).
<<"Use POST request method">>, Req, State).

%%--------------------------------------------------------------------
%% BOSH Terminal Binding Error Conditions
%%--------------------------------------------------------------------

-spec terminal_condition(binary(), cowboy_req:req()) -> cowboy_req:req().
terminal_condition(Condition, Req) ->
terminal_condition(Condition, [], Req).
-spec terminal_condition(binary(), cowboy_req:req(), rstate()) -> cowboy_req:req().
terminal_condition(Condition, Req, State) ->
terminal_condition(Condition, [], Req, State).


-spec terminal_condition(binary(), [exml:element()], cowboy_req:req())
-spec terminal_condition(binary(), [exml:element()], cowboy_req:req(), rstate())
-> cowboy_req:req().
terminal_condition(Condition, Details, Req) ->
terminal_condition(Condition, Details, Req, State) ->
Body = terminal_condition_body(Condition, Details),
Headers = [content_type()] ++ ac_all(?DEFAULT_ALLOW_ORIGIN),
cowboy_reply(200, Headers, Body, Req).
cowboy_reply(200, Headers, Body, Req, State).


-spec terminal_condition_body(binary(), [exml:element()]) -> binary().
Expand Down Expand Up @@ -481,9 +483,10 @@ maybe_set_max_hold(ClientHold, #xmlel{attrs = Attrs} = Body) when ClientHold > 1
maybe_set_max_hold(_, _) ->
{error, invalid_hold}.

-spec cowboy_reply(non_neg_integer(), headers_list(), binary(), req()) -> req().
cowboy_reply(Code, Headers, Body, Req) when is_list(Headers) ->
cowboy_req:reply(Code, maps:from_list(Headers), Body, Req).
-spec cowboy_reply(non_neg_integer(), headers_list(), binary(), req(), rstate()) -> req().
cowboy_reply(Code, Headers, Body, Req, State) when is_list(Headers) ->
CustomHeaders = State#rstate.custom_headers,
cowboy_req:reply(Code, maps:from_list(Headers ++ CustomHeaders), Body, Req).

config_metrics(Host) ->
OptsToReport = [{backend, mnesia}], %list of tuples {option, defualt_value}
Expand Down
9 changes: 7 additions & 2 deletions src/mod_websockets.erl
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,10 @@ init(Req, Opts) ->
?LOG_DEBUG(#{what => ws_init, text => <<"New websockets request">>,
req => Req, opts => Opts}),
Timeout = gen_mod:get_opt(timeout, Opts, 60000),

Req2 = maybe_add_custom_headers(Req1, Opts),
AllModOpts = [{peer, Peer}, {peercert, PeerCert} | Opts],
%% upgrade protocol
{cowboy_websocket, Req1, AllModOpts, #{idle_timeout => Timeout}}.
{cowboy_websocket, Req2, AllModOpts, #{idle_timeout => Timeout}}.

terminate(_Reason, _Req, _State) ->
ok.
Expand Down Expand Up @@ -361,6 +361,11 @@ should_have_jabber_client(#xmlel{name = <<"message">>}) -> true;
should_have_jabber_client(#xmlel{name = <<"presence">>}) -> true;
should_have_jabber_client(_) -> false.

maybe_add_custom_headers(Req, Opts) ->
CustomHeaders = gen_mod:get_opt(custom_headers, Opts, []),
CustomHeadersMap = maps:from_list(CustomHeaders),
cowboy_req:set_resp_headers(CustomHeadersMap, Req).

send_ping_request(PingRate) ->
Dest = self(),
?LOG_DEBUG(#{what => ws_schedule_ping,
Expand Down
81 changes: 81 additions & 0 deletions test/mod_bosh_SUITE.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
-module(mod_bosh_SUITE).
-compile([export_all]).
-include_lib("eunit/include/eunit.hrl").
-define(PORT, 5280).
-define(IP, {127, 0, 0, 1}).
-define(CUSTOM_HEADERS,
[{<<"strict-transport-security">>, <<"max-age=31536000; includeSubDomains">>},
{<<"access-control-allow-origin">>, <<"*">>}]).
-define(BOSH_BIND_PATH, "/http-bind").
-define(HANDSHAKE_TIMEOUT, 3000).
-define(eq(E, I), ?assertEqual(E, I)).
-define(FAST_PING_RATE, 500).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of these defines can be removed. I suspect it's a direct copy & paste from websockets suite? :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is indeed

-define(NEW_TIMEOUT, 1200).
%The timeout is long enough to pass all test cases for ping interval settings
%using NEW_TIMEOUT value. In these tests we wait for at most 2 pings.
%The 300ms is just an additional overhead
-define(IDLE_TIMEOUT, ?NEW_TIMEOUT * 2 + 300).

all() ->
custom_headers_tests().

custom_headers_tests() ->
[verify_custom_headers].

init_per_suite(C) ->
setup(),
C.

end_per_suite(_) ->
teardown(),
ok.

init_per_testcase(_, C) ->
C.

end_per_testcase(_, C) ->
C.


setup() ->
meck:unload(),
application:ensure_all_started(cowboy),
application:ensure_all_started(jid),
meck:new(supervisor, [unstick, passthrough, no_link]),
meck:new(gen_mod,[unstick, passthrough, no_link]),
%% Set ping rate
meck:expect(supervisor, start_child,
fun(ejabberd_listeners, {_, {_, start_link, [_]}, transient,
infinity, worker, [_]}) -> {ok, self()};
(A,B) -> meck:passthrough([A,B])
end),
meck:expect(ejabberd_config, get_local_option, fun(_) -> undefined end),
%% Start websocket cowboy listening

Opts = [{num_acceptors, 10},
{max_connections, 1024},
{modules, [{"_", "/http-bind", mod_bosh, [{custom_headers, ?CUSTOM_HEADERS}]}]}],
ejabberd_cowboy:start_listener({?PORT, ?IP, tcp}, Opts).

teardown() ->
meck:unload(),
cowboy:stop_listener(ejabberd_cowboy:ref({?PORT, ?IP, tcp})),
application:stop(cowboy),
%% Do not stop jid, Erlang 21 does not like to reload nifs
ok.

verify_custom_headers(_Config) ->
application:ensure_all_started(gun),
{ok, ConnPid} = gun:open("127.0.0.1", ?PORT),
{ok, http} = gun:await_up(ConnPid),
StreamRef = gun:get(ConnPid, ?BOSH_BIND_PATH),
case gun:await(ConnPid, StreamRef) of
{response, fin, _Status, _Headers} ->
ct:fail(no_bosh_http_response);
{response, nofin, 200, Headers} ->
{ok, _Body} = gun:await_body(ConnPid, StreamRef),
RcvdHdrsSet = sets:from_list(Headers),
CustomHdrsSet = sets:from_list(?CUSTOM_HEADERS),
?assert(sets:is_subset(CustomHdrsSet, RcvdHdrsSet))
end,
application:stop(gun).
Loading