Skip to content

Commit

Permalink
Merge pull request #3116 from esl/multi-tenancy-subdomains
Browse files Browse the repository at this point in the history
adding mongoose_subdomain_core
  • Loading branch information
chrzaszcz authored May 14, 2021
2 parents d26aca1 + 3e0a1a8 commit 4a31a98
Show file tree
Hide file tree
Showing 5 changed files with 995 additions and 1 deletion.
2 changes: 2 additions & 0 deletions src/domain/mongoose_domain_core.erl
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ handle_delete(Domain) ->
ok;
[{Domain, HostType, _Source}] ->
ets:delete(?TABLE, Domain),
mongoose_subdomain_core:remove_domain(HostType, Domain),
mongoose_hooks:disable_domain(HostType, Domain),
ok
end.
Expand All @@ -212,6 +213,7 @@ handle_insert(Domain, HostType, Source) ->
{error, static};
[] ->
ets:insert_new(?TABLE, new_object(Domain, HostType, {dynamic, Source})),
mongoose_subdomain_core:add_domain(HostType, Domain),
ok;
[{Domain, HT, _Source}] when HT =:= HostType ->
ets:insert(?TABLE, new_object(Domain, HostType, {dynamic, Source})),
Expand Down
334 changes: 334 additions & 0 deletions src/domain/mongoose_subdomain_core.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,334 @@
%% Generally, you should not call anything from this module.
%% Use mongoose_domain_api module instead.
-module(mongoose_subdomain_core).
-behaviour(gen_server).

-include("mongoose_logger.hrl").
%% API
-export([start/0, stop/0]).
-export([start_link/0]).

-export([register_subdomain/3,
unregister_subdomain/2,
add_domain/2,
remove_domain/2,
sync/0]).

-export([get_host_type/1,
get_subdomain_info/1,
get_all_subdomains_for_domain/1]).

%% gen_server callbacks
-export([init/1,
handle_call/3,
handle_cast/2,
handle_info/2,
code_change/3,
terminate/2]).

-ifdef(TEST).

-undef(LOG_ERROR).
-export([log_error/2]).
-define(LOG_ERROR(Error), ?MODULE:log_error(?FUNCTION_NAME, Error)).

-endif.

-define(SUBDOMAINS_TABLE, ?MODULE).
-define(REGISTRATION_TABLE, mongoose_subdomain_reg).

-type host_type() :: mongooseim:host_type().
-type domain() :: mongooseim:domain_name().
-type subdomain_pattern() :: mongoose_subdomain_utils:subdomain_pattern().
-type maybe_parent_domain() :: domain() | no_parent_domain.

-type reg_item() :: {{host_type(), subdomain_pattern()}, %% table key
Type :: fqdn | subdomain,
mongoose_packet_handler:t()}.

-record(subdomain_item, {host_type :: host_type() | '_',
subdomain :: domain() | '_', %% table key
subdomain_pattern :: subdomain_pattern() | '_',
parent_domain :: maybe_parent_domain() | '_',
packet_handler :: mongoose_packet_handler:t() | '_'}).

-type subdomain_item() :: #subdomain_item{}.

%% corresponds to the fields in #subdomain_item{} record
-type subdomain_info() :: #{host_type := host_type(),
subdomain := domain(),
subdomain_pattern := subdomain_pattern(),
parent_domain := maybe_parent_domain(),
packet_handler := mongoose_packet_handler:t()}.

-export_type([subdomain_info/0]).

%%--------------------------------------------------------------------
%% API
%%--------------------------------------------------------------------
-ifdef(TEST).
%% required for unit tests
start() ->
just_ok(gen_server:start({local, ?MODULE}, ?MODULE, [], [])).

stop() ->
gen_server:stop(?MODULE).

%% this interface is required only to detect errors in unit tests
log_error(_Function, _Error) -> ok.

-else.

start() ->
ChildSpec = {?MODULE, {?MODULE, start_link, []},
permanent, infinity, worker, [?MODULE]},
just_ok(supervisor:start_child(ejabberd_sup, ChildSpec)).

%% required for integration tests
stop() ->
supervisor:terminate_child(ejabberd_sup, ?MODULE),
supervisor:delete_child(ejabberd_sup, ?MODULE),
ok.

-endif.

start_link() ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).

-spec register_subdomain(host_type(), subdomain_pattern(),
mongoose_packet_handler:t()) ->
ok | {error, already_registered | subdomain_already_exists}.
register_subdomain(HostType, SubdomainPattern, PacketHandler) ->
gen_server:call(?MODULE, {register, HostType, SubdomainPattern, PacketHandler}).

-spec unregister_subdomain(host_type(), subdomain_pattern()) -> ok.
unregister_subdomain(HostType, SubdomainPattern) ->
gen_server:call(?MODULE, {unregister, HostType, SubdomainPattern}).

-spec sync() -> ok.
sync() ->
gen_server:call(?MODULE, sync).

-spec add_domain(host_type(), domain()) -> ok.
add_domain(HostType, Domain) ->
gen_server:cast(?MODULE, {add_domain, HostType, Domain}).

-spec remove_domain(host_type(), domain()) -> ok.
remove_domain(HostType, Domain) ->
gen_server:cast(?MODULE, {remove_domain, HostType, Domain}).

-spec get_host_type(Subdomain :: domain()) -> {ok, host_type()} | {error, not_found}.
get_host_type(Subdomain) ->
case ets:lookup(?SUBDOMAINS_TABLE, Subdomain) of
[] ->
{error, not_found};
[#subdomain_item{host_type = HostType}] ->
{ok, HostType}
end.

-spec get_subdomain_info(Subdomain :: domain()) -> {ok, subdomain_info()} | {error, not_found}.
get_subdomain_info(Subdomain) ->
case ets:lookup(?SUBDOMAINS_TABLE, Subdomain) of
[] ->
{error, not_found};
[Item] ->
{ok, convert_subdomain_item_to_map(Item)}
end.

-spec get_all_subdomains_for_domain(Domain :: maybe_parent_domain()) -> [subdomain_info()].
%% if Domain param is set to no_parent_domain,
%% this function returns all the FQDN "subdomains".
get_all_subdomains_for_domain(Domain) ->
Pattern = #subdomain_item{parent_domain = Domain, _ = '_'},
Match = ets:match_object(?SUBDOMAINS_TABLE, Pattern),
[convert_subdomain_item_to_map(Item) || Item <- Match].

%%--------------------------------------------------------------------
%% gen_server callbacks
%%--------------------------------------------------------------------
init([]) ->
ets:new(?SUBDOMAINS_TABLE, [set, named_table, protected,
{keypos, #subdomain_item.subdomain},
{read_concurrency, true}]),
%% ?REGISTRATION_TABLE is protected only for traceability purposes
ets:new(?REGISTRATION_TABLE, [set, named_table, protected]),
%% no need for state, everything is kept in ETS tables
{ok, ok}.

handle_call({register, HostType, SubdomainPattern, PacketHandler}, From, State) ->
%% handle_register/4 must reply to the caller using gen_server:reply/2 interface
handle_register(HostType, SubdomainPattern, PacketHandler, From),
{noreply, State};
handle_call({unregister, HostType, SubdomainPattern}, _From, State) ->
Result = handle_unregister(HostType, SubdomainPattern),
{reply, Result, State};
handle_call(sync, _From, State) ->
{reply, ok, State};
handle_call(Request, From, State) ->
?UNEXPECTED_CALL(Request, From),
{reply, ok, State}.

handle_cast({add_domain, HostType, Domain}, State) ->
handle_add_domain(HostType, Domain),
{noreply, State};
handle_cast({remove_domain, HostType, Domain}, State) ->
handle_remove_domain(HostType, Domain),
{noreply, State};
handle_cast(Msg, State) ->
?UNEXPECTED_CAST(Msg),
{noreply, State}.

handle_info(Info, State) ->
?UNEXPECTED_INFO(Info),
{noreply, State}.

terminate(_Reason, _State) ->
ok.

code_change(_OldVsn, State, _Extra) ->
{ok, State}.

%%--------------------------------------------------------------------
%% local functions
%%--------------------------------------------------------------------
just_ok({ok, _}) -> ok;
just_ok(Other) -> Other.

-spec handle_register(host_type(), subdomain_pattern(),
mongoose_packet_handler:t(), any()) -> ok.
handle_register(HostType, SubdomainPattern, PacketHandler, From) ->
SubdomainType = mongoose_subdomain_utils:subdomain_type(SubdomainPattern),
RegItem = {{HostType, SubdomainPattern}, SubdomainType, PacketHandler},
case ets:insert_new(?REGISTRATION_TABLE, RegItem) of
true ->
case SubdomainType of
subdomain ->
Fn = fun(_HostType, Subdomain) ->
add_subdomain(RegItem, Subdomain)
end,
%% mongoose_domain_core:for_each_domain/2 can take quite a long,
%% reply before running it.
gen_server:reply(From, ok),
mongoose_domain_core:for_each_domain(HostType, Fn);
fqdn ->
Result = add_subdomain(RegItem, no_parent_domain),
gen_server:reply(From, Result)
end;
false ->
gen_server:reply(From, {error, already_registered})
end.

-spec handle_unregister(host_type(), subdomain_pattern()) -> ok.
handle_unregister(HostType, SubdomainPattern) ->
Pattern = #subdomain_item{subdomain_pattern = SubdomainPattern,
host_type = HostType, _ = '_'},
Match = ets:match_object(?SUBDOMAINS_TABLE, Pattern),
remove_subdomains(Match),
ets:delete(?REGISTRATION_TABLE, {HostType, SubdomainPattern}),
ok.

-spec handle_add_domain(host_type(), domain()) -> ok.
handle_add_domain(HostType, Domain) ->
check_domain_name(HostType, Domain),
%% even if the domain name check fails, it's not easy to solve this
%% collision. so the best thing we can do is to report it and just keep
%% the data in both ETS tables (domains and subdomains) for further
%% troubleshooting.
Match = ets:match_object(?REGISTRATION_TABLE, {{HostType, '_'}, subdomain, '_'}),
add_subdomains(Match, Domain).

-spec handle_remove_domain(host_type(), domain()) -> ok.
handle_remove_domain(HostType, Domain) ->
Pattern = #subdomain_item{parent_domain = Domain, host_type = HostType, _ = '_'},
Match = ets:match_object(?SUBDOMAINS_TABLE, Pattern),
remove_subdomains(Match).

-spec remove_subdomains([subdomain_item()]) -> ok.
remove_subdomains(SubdomainItems) ->
Fn = fun(#subdomain_item{host_type = HostType, subdomain = Subdomain}) ->
remove_subdomain(HostType, Subdomain)
end,
lists:foreach(Fn, SubdomainItems).

-spec remove_subdomain(host_type(), domain()) -> true.
remove_subdomain(HostType, Subdomain) ->
mongoose_hooks:disable_subdomain(HostType, Subdomain),
ets:delete(?SUBDOMAINS_TABLE, Subdomain).

-spec add_subdomains([reg_item()], domain()) -> ok.
add_subdomains(RegItems, Domain) ->
Fn = fun(RegItem) ->
add_subdomain(RegItem, Domain)
end,
lists:foreach(Fn, RegItems).

-spec add_subdomain(reg_item(), maybe_parent_domain()) -> ok | {error, already_registered}.
add_subdomain(RegItem, Domain) ->
#subdomain_item{subdomain = Subdomain} = Item = make_subdomain_item(RegItem, Domain),
case ets:insert_new(?SUBDOMAINS_TABLE, Item) of
true ->
check_subdomain_name(Item),
%% even if the subdomain name check fails, it's not easy to solve this
%% collision. so the best thing we can do is to report it and just keep
%% the data in both ETS tables (domains and subdomains) for further
%% troubleshooting.
ok;
false ->
case ets:lookup(?SUBDOMAINS_TABLE, Subdomain) of
[Item] ->
ok; %% exactly the same item is already inserted, it's fine.
[ExistingItem] ->
report_subdomains_collision(ExistingItem, Item),
{error, subdomain_already_exists}
end
end.

-spec make_subdomain_item(reg_item(), maybe_parent_domain()) -> subdomain_item().
make_subdomain_item({{HostType, SubdomainPattern}, Type, PacketHandler}, Domain) ->
Subdomain = case {Type, Domain} of
{fqdn, no_parent_domain} ->
%% not a subdomain, but FQDN
mongoose_subdomain_utils:get_fqdn(SubdomainPattern, <<"">>);
{subdomain, Domain} when is_binary(Domain) ->
mongoose_subdomain_utils:get_fqdn(SubdomainPattern, Domain)
end,
#subdomain_item{host_type = HostType, subdomain = Subdomain, parent_domain = Domain,
subdomain_pattern = SubdomainPattern, packet_handler = PacketHandler}.

-spec convert_subdomain_item_to_map(subdomain_item()) -> subdomain_info().
convert_subdomain_item_to_map(#subdomain_item{} = Item) ->
Fields = record_info(fields, subdomain_item),
[_ | Values] = tuple_to_list(Item),
KVList = lists:zip(Fields, Values),
maps:from_list(KVList).

-spec check_domain_name(mongooseim:host_type(), mongooseim:domain_name()) ->
boolean().
check_domain_name(_HostType, Domain) ->
case mongoose_subdomain_core:get_subdomain_info(Domain) of
{error, not_found} -> true;
{ok, _Info} ->
%% TODO: this is critical collision, and it must be reported properly
%% think about adding some metric, so devops can set some alarm for it
?LOG_ERROR(#{what => check_domain_name_failed, domain => Domain}),
false
end.

-spec check_subdomain_name(subdomain_item()) -> boolean().
check_subdomain_name(#subdomain_item{subdomain = Subdomain} = _SubdomainItem) ->
case mongoose_domain_core:get_host_type(Subdomain) of
{error, not_found} -> true;
{ok, _HostType} ->
%% TODO: this is critical collision, and it must be reported properly
%% think about adding some metric, so devops can set some alarm for it
?LOG_ERROR(#{what => check_subdomain_name_failed, subdomain => Subdomain}),
false
end.

-spec report_subdomains_collision(subdomain_item(), subdomain_item()) -> ok.
report_subdomains_collision(ExistingSubdomainItem, _NewSubdomainItem) ->
#subdomain_item{subdomain = Subdomain} = ExistingSubdomainItem,
%% TODO: this is critical collision, and it must be reported properly
%% think about adding some metric, so devops can set some alarm for it
?LOG_ERROR(#{what => subdomains_collision, subdomain => Subdomain}),
ok.
5 changes: 5 additions & 0 deletions src/domain/mongoose_subdomain_utils.erl
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
%% API
-export([make_subdomain_pattern/1,
get_fqdn/2,
subdomain_type/1,
is_subdomain/3]).

-type subdomain_pattern() :: {fqdn | prefix, binary()}.
Expand All @@ -28,6 +29,10 @@ make_subdomain_pattern(ConfigOpt) when is_binary(ConfigOpt) ->
get_fqdn({fqdn, Subdomain}, _Domain) -> Subdomain;
get_fqdn({prefix, Prefix}, Domain) -> <<Prefix/binary, Domain/binary>>.

-spec subdomain_type(subdomain_pattern()) -> fqdn | subdomain.
subdomain_type({fqdn, _}) -> fqdn;
subdomain_type({prefix, _}) -> subdomain.

-spec is_subdomain(subdomain_pattern(),
Domain :: mongooseim:domain_name(),
Subdomain :: mongooseim:domain_name()) -> boolean().
Expand Down
11 changes: 10 additions & 1 deletion src/mongoose_hooks.erl
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@
-export([c2s_remote_hook/5]).

-export([disable_domain/2,
disable_subdomain/2,
remove_domain/2,
node_cleanup/1]).

Expand Down Expand Up @@ -233,12 +234,20 @@ auth_failed(HostType, Server, Username) ->
ejabberd_hooks:run_for_host_type(auth_failed, HostType, ok, [Username, Server]).

-spec disable_domain(HostType, Domain) -> Result when
HostType :: binary(),
HostType :: mongooseim:host_type(),
Domain :: jid:lserver(),
Result :: ok.
disable_domain(HostType, Domain) ->
ejabberd_hooks:run_global(disable_domain, ok, [HostType, Domain]).


-spec disable_subdomain(HostType, Subdomain) -> Result when
HostType :: mongooseim:host_type(),
Subdomain :: jid:lserver(),
Result :: ok.
disable_subdomain(HostType, Subdomain) ->
ejabberd_hooks:run_global(disable_subdomain, ok, [HostType, Subdomain]).

-spec remove_domain(HostType, Domain) -> Result when
HostType :: binary(),
Domain :: jid:lserver(),
Expand Down
Loading

0 comments on commit 4a31a98

Please sign in to comment.