mirror of
https://github.com/processone/ejabberd.git
synced 2024-12-22 17:28:25 +01:00
Add OAuth support (thanks to Aleksey)
This commit is contained in:
parent
7cf904f4cf
commit
a1129dc96b
15
configure.ac
15
configure.ac
@ -108,10 +108,10 @@ AC_ARG_ENABLE(mssql,
|
||||
esac],[db_type=generic])
|
||||
|
||||
AC_ARG_ENABLE(all,
|
||||
[AC_HELP_STRING([--enable-all], [same as --enable-nif --enable-odbc --enable-mysql --enable-pgsql --enable-sqlite --enable-pam --enable-zlib --enable-riak --enable-redis --enable-elixir --enable-iconv --enable-debug --enable-lager --enable-tools (useful for Dialyzer checks, default: no)])],
|
||||
[AC_HELP_STRING([--enable-all], [same as --enable-nif --enable-odbc --enable-mysql --enable-pgsql --enable-sqlite --enable-pam --enable-zlib --enable-riak --enable-redis --enable-elixir --enable-iconv --enable-debug --enable-lager --enable-tools --enable-oauth (useful for Dialyzer checks, default: no)])],
|
||||
[case "${enableval}" in
|
||||
yes) nif=true odbc=true mysql=true pgsql=true sqlite=true pam=true zlib=true riak=true redis=true elixir=true iconv=true debug=true lager=true tools=true ;;
|
||||
no) nif=false odbc=false mysql=false pgsql=false sqlite=false pam=false zlib=false riak=false redis=false elixir=false iconv=false debug=false lager=false tools=false ;;
|
||||
yes) nif=true odbc=true mysql=true pgsql=true sqlite=true pam=true zlib=true riak=true redis=true elixir=true iconv=true debug=true lager=true tools=true oauth=true ;;
|
||||
no) nif=false odbc=false mysql=false pgsql=false sqlite=false pam=false zlib=false riak=false redis=false elixir=false iconv=false debug=false lager=false tools=false oauth=false ;;
|
||||
*) AC_MSG_ERROR(bad value ${enableval} for --enable-all) ;;
|
||||
esac],[])
|
||||
|
||||
@ -227,6 +227,14 @@ AC_ARG_ENABLE(lager,
|
||||
*) AC_MSG_ERROR(bad value ${enableval} for --enable-lager) ;;
|
||||
esac],[if test "x$lager" = "x"; then lager=true; fi])
|
||||
|
||||
AC_ARG_ENABLE(oauth,
|
||||
[AC_HELP_STRING([--enable-oauth], [enable oauth support (default: yes)])],
|
||||
[case "${enableval}" in
|
||||
yes) oauth=true ;;
|
||||
no) oauth=false ;;
|
||||
*) AC_MSG_ERROR(bad value ${enableval} for --enable-oauth) ;;
|
||||
esac],[if test "x$oauth" = "x"; then oauth=false; fi])
|
||||
|
||||
AC_CONFIG_FILES([Makefile
|
||||
vars.config
|
||||
src/ejabberd.app.src])
|
||||
@ -273,5 +281,6 @@ AC_SUBST(iconv)
|
||||
AC_SUBST(debug)
|
||||
AC_SUBST(lager)
|
||||
AC_SUBST(tools)
|
||||
AC_SUBST(oauth)
|
||||
|
||||
AC_OUTPUT
|
||||
|
@ -34,6 +34,7 @@
|
||||
module :: atom(),
|
||||
function :: atom(),
|
||||
args = [] :: [aterm()] | '_' | '$1' | '$2',
|
||||
policy = restricted :: open | restricted | admin | user,
|
||||
result = {res, rescode} :: rterm() | '_' | '$2'}).
|
||||
|
||||
-type ejabberd_commands() :: #ejabberd_commands{name :: atom(),
|
||||
|
@ -110,6 +110,11 @@ CfgDeps = lists:flatmap(
|
||||
[{meck, "0.*", {git, "https://github.com/eproxus/meck"}}];
|
||||
({redis, true}) ->
|
||||
[{eredis, ".*", {git, "https://github.com/wooga/eredis"}}];
|
||||
({oauth, true}) ->
|
||||
%% ejabberd oauth support does not depends directly on xmlrpc.
|
||||
%% However, we include xmlrpc as it is convenient to document and test oauth
|
||||
[{oauth2, ".*", {git, "https://github.com/IvanMartinez/oauth2.git"}},
|
||||
{xmlrpc, ".*", {git, "https://github.com/rds13/xmlrpc.git"}}];
|
||||
(_) ->
|
||||
[]
|
||||
end, Cfg),
|
||||
|
@ -84,6 +84,7 @@ start() ->
|
||||
cyrsasl_digest:start([]),
|
||||
cyrsasl_scram:start([]),
|
||||
cyrsasl_anonymous:start([]),
|
||||
cyrsasl_oauth:start([]),
|
||||
ok.
|
||||
|
||||
%%
|
||||
|
96
src/cyrsasl_oauth.erl
Normal file
96
src/cyrsasl_oauth.erl
Normal file
@ -0,0 +1,96 @@
|
||||
%%%----------------------------------------------------------------------
|
||||
%%% File : cyrsasl_oauth.erl
|
||||
%%% Author : Alexey Shchepin <alexey@process-one.net>
|
||||
%%% Purpose : X-OAUTH2 SASL mechanism
|
||||
%%% Created : 17 Sep 2015 by Alexey Shchepin <alexey@process-one.net>
|
||||
%%%
|
||||
%%%
|
||||
%%% ejabberd, Copyright (C) 2002-2015 ProcessOne
|
||||
%%%
|
||||
%%% This program is free software; you can redistribute it and/or
|
||||
%%% modify it under the terms of the GNU General Public License as
|
||||
%%% published by the Free Software Foundation; either version 2 of the
|
||||
%%% License, or (at your option) any later version.
|
||||
%%%
|
||||
%%% This program is distributed in the hope that it will be useful,
|
||||
%%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
%%% General Public License for more details.
|
||||
%%%
|
||||
%%% You should have received a copy of the GNU General Public License along
|
||||
%%% with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
%%%
|
||||
%%%----------------------------------------------------------------------
|
||||
|
||||
-module(cyrsasl_oauth).
|
||||
|
||||
-author('alexey@process-one.net').
|
||||
|
||||
-export([start/1, stop/0, mech_new/6, mech_step/2, parse/1]).
|
||||
|
||||
-behaviour(cyrsasl).
|
||||
|
||||
-record(state, {host, is_user_exists}).
|
||||
|
||||
start(_Opts) ->
|
||||
cyrsasl:register_mechanism(<<"X-OAUTH2">>, ?MODULE, plain),
|
||||
ok.
|
||||
|
||||
stop() -> ok.
|
||||
|
||||
mech_new(Host, _GetPassword, _CheckPassword, _CheckPasswordDigest,
|
||||
IsUserExists, _ClientCertFile) ->
|
||||
{ok, #state{host = Host, is_user_exists = IsUserExists}}.
|
||||
|
||||
mech_step(State, ClientIn) ->
|
||||
case prepare(ClientIn) of
|
||||
[AuthzId, User, Token] ->
|
||||
case (State#state.is_user_exists)(User) of
|
||||
true ->
|
||||
case ejabberd_oauth:check_token(
|
||||
User, State#state.host, <<"sasl_auth">>, Token) of
|
||||
true ->
|
||||
{ok,
|
||||
[{username, User}, {authzid, AuthzId},
|
||||
{auth_module, ejabberd_oauth}]};
|
||||
false ->
|
||||
{error, <<"not-authorized">>, User}
|
||||
end;
|
||||
_ -> {error, <<"not-authorized">>, User}
|
||||
end;
|
||||
_ -> {error, <<"bad-protocol">>}
|
||||
end.
|
||||
|
||||
prepare(ClientIn) ->
|
||||
case parse(ClientIn) of
|
||||
[<<"">>, UserMaybeDomain, Token] ->
|
||||
case parse_domain(UserMaybeDomain) of
|
||||
%% <NUL>login@domain<NUL>pwd
|
||||
[User, _Domain] -> [UserMaybeDomain, User, Token];
|
||||
%% <NUL>login<NUL>pwd
|
||||
[User] -> [<<"">>, User, Token]
|
||||
end;
|
||||
%% login@domain<NUL>login<NUL>pwd
|
||||
[AuthzId, User, Token] -> [AuthzId, User, Token];
|
||||
_ -> error
|
||||
end.
|
||||
|
||||
parse(S) -> parse1(binary_to_list(S), "", []).
|
||||
|
||||
parse1([0 | Cs], S, T) ->
|
||||
parse1(Cs, "", [list_to_binary(lists:reverse(S)) | T]);
|
||||
parse1([C | Cs], S, T) -> parse1(Cs, [C | S], T);
|
||||
%parse1([], [], T) ->
|
||||
% lists:reverse(T);
|
||||
parse1([], S, T) ->
|
||||
lists:reverse([list_to_binary(lists:reverse(S)) | T]).
|
||||
|
||||
parse_domain(S) -> parse_domain1(binary_to_list(S), "", []).
|
||||
|
||||
parse_domain1([$@ | Cs], S, T) ->
|
||||
parse_domain1(Cs, "", [list_to_binary(lists:reverse(S)) | T]);
|
||||
parse_domain1([C | Cs], S, T) ->
|
||||
parse_domain1(Cs, [C | S], T);
|
||||
parse_domain1([], S, T) ->
|
||||
lists:reverse([list_to_binary(lists:reverse(S)) | T]).
|
@ -69,6 +69,7 @@ start(normal, _Args) ->
|
||||
%ejabberd_debug:fprof_start(),
|
||||
maybe_add_nameservers(),
|
||||
ejabberd_auth:start(),
|
||||
ejabberd_oauth:start(),
|
||||
start_modules(),
|
||||
ejabberd_listener:start_listeners(),
|
||||
?INFO_MSG("ejabberd ~s is started in the node ~p", [?VERSION, node()]),
|
||||
|
@ -211,12 +211,15 @@
|
||||
-export([init/0,
|
||||
list_commands/0,
|
||||
get_command_format/1,
|
||||
get_command_format/2,
|
||||
get_command_definition/1,
|
||||
get_tags_commands/0,
|
||||
get_commands/0,
|
||||
register_commands/1,
|
||||
unregister_commands/1,
|
||||
execute_command/2,
|
||||
execute_command/4
|
||||
execute_command/4,
|
||||
opt_type/1
|
||||
]).
|
||||
|
||||
-include("ejabberd_commands.hrl").
|
||||
@ -265,19 +268,39 @@ list_commands() ->
|
||||
_ = '_'}),
|
||||
[{A, B, C} || [A, B, C] <- Commands].
|
||||
|
||||
-spec list_commands_policy() -> [{atom(), [aterm()], string(), atom()}].
|
||||
|
||||
%% @doc Get a list of all the available commands, arguments, description, and
|
||||
%% policy.
|
||||
list_commands_policy() ->
|
||||
Commands = ets:match(ejabberd_commands,
|
||||
#ejabberd_commands{name = '$1',
|
||||
args = '$2',
|
||||
desc = '$3',
|
||||
policy = '$4',
|
||||
_ = '_'}),
|
||||
[{A, B, C, D} || [A, B, C, D] <- Commands].
|
||||
|
||||
-spec get_command_format(atom()) -> {[aterm()], rterm()} | {error, command_unknown}.
|
||||
|
||||
%% @doc Get the format of arguments and result of a command.
|
||||
get_command_format(Name) ->
|
||||
get_command_format(Name, noauth).
|
||||
|
||||
get_command_format(Name, Auth) ->
|
||||
Admin = is_admin(Name, Auth),
|
||||
Matched = ets:match(ejabberd_commands,
|
||||
#ejabberd_commands{name = Name,
|
||||
args = '$1',
|
||||
result = '$2',
|
||||
policy = '$3',
|
||||
_ = '_'}),
|
||||
case Matched of
|
||||
[] ->
|
||||
{error, command_unknown};
|
||||
[[Args, Result]] ->
|
||||
[[Args, Result, user]] when Admin ->
|
||||
{[{user, binary}, {server, binary} | Args], Result};
|
||||
[[Args, Result, _]] ->
|
||||
{Args, Result}
|
||||
end.
|
||||
|
||||
@ -295,24 +318,54 @@ get_command_definition(Name) ->
|
||||
execute_command(Name, Arguments) ->
|
||||
execute_command([], noauth, Name, Arguments).
|
||||
|
||||
-spec execute_command([{atom(), [atom()], [any()]}],
|
||||
{binary(), binary(), binary(), boolean()} |
|
||||
noauth | admin,
|
||||
atom(),
|
||||
[any()]
|
||||
) -> any().
|
||||
|
||||
%% @spec (AccessCommands, Auth, Name::atom(), Arguments) -> ResultTerm | {error, Error}
|
||||
%% where
|
||||
%% AccessCommands = [{Access, CommandNames, Arguments}]
|
||||
%% Auth = {User::string(), Server::string(), Password::string()} | noauth
|
||||
%% Auth = {User::string(), Server::string(), Password::string(), Admin::boolean()}
|
||||
%% | noauth
|
||||
%% | admin
|
||||
%% Method = atom()
|
||||
%% Arguments = [any()]
|
||||
%% Error = command_unknown | account_unprivileged | invalid_account_data | no_auth_provided
|
||||
execute_command(AccessCommands, Auth, Name, Arguments) ->
|
||||
execute_command(AccessCommands1, Auth1, Name, Arguments) ->
|
||||
Auth = case is_admin(Name, Auth1) of
|
||||
true -> admin;
|
||||
false -> Auth1
|
||||
end,
|
||||
case ets:lookup(ejabberd_commands, Name) of
|
||||
[Command] ->
|
||||
AccessCommands = get_access_commands(AccessCommands1),
|
||||
try check_access_commands(AccessCommands, Auth, Name, Command, Arguments) of
|
||||
ok -> execute_command2(Command, Arguments)
|
||||
ok -> execute_command2(Auth, Command, Arguments)
|
||||
catch
|
||||
{error, Error} -> {error, Error}
|
||||
end;
|
||||
[] -> {error, command_unknown}
|
||||
end.
|
||||
|
||||
execute_command2(
|
||||
_Auth, #ejabberd_commands{policy = open} = Command, Arguments) ->
|
||||
execute_command2(Command, Arguments);
|
||||
execute_command2(
|
||||
_Auth, #ejabberd_commands{policy = restricted} = Command, Arguments) ->
|
||||
execute_command2(Command, Arguments);
|
||||
execute_command2(
|
||||
_Auth, #ejabberd_commands{policy = admin} = Command, Arguments) ->
|
||||
execute_command2(Command, Arguments);
|
||||
execute_command2(
|
||||
admin, #ejabberd_commands{policy = user} = Command, Arguments) ->
|
||||
execute_command2(Command, Arguments);
|
||||
execute_command2(
|
||||
{User, Server, _, _}, #ejabberd_commands{policy = user} = Command, Arguments) ->
|
||||
execute_command2(Command, [User, Server | Arguments]).
|
||||
|
||||
execute_command2(Command, Arguments) ->
|
||||
Module = Command#ejabberd_commands.module,
|
||||
Function = Command#ejabberd_commands.function,
|
||||
@ -372,11 +425,20 @@ get_tags_commands() ->
|
||||
%% Error = account_unprivileged | invalid_account_data
|
||||
check_access_commands([], _Auth, _Method, _Command, _Arguments) ->
|
||||
ok;
|
||||
check_access_commands(AccessCommands, Auth, Method, Command, Arguments) ->
|
||||
check_access_commands(AccessCommands, Auth, Method, Command1, Arguments) ->
|
||||
Command =
|
||||
case {Command1#ejabberd_commands.policy, Auth} of
|
||||
{user, admin} ->
|
||||
Command1#ejabberd_commands{
|
||||
args = [{user, binary}, {server, binary} |
|
||||
Command1#ejabberd_commands.args]};
|
||||
_ ->
|
||||
Command1
|
||||
end,
|
||||
AccessCommandsAllowed =
|
||||
lists:filter(
|
||||
fun({Access, Commands, ArgumentRestrictions}) ->
|
||||
case check_access(Access, Auth) of
|
||||
case check_access(Command, Access, Auth) of
|
||||
true ->
|
||||
check_access_command(Commands, Command, ArgumentRestrictions,
|
||||
Method, Arguments);
|
||||
@ -385,7 +447,7 @@ check_access_commands(AccessCommands, Auth, Method, Command, Arguments) ->
|
||||
end;
|
||||
({Access, Commands}) ->
|
||||
ArgumentRestrictions = [],
|
||||
case check_access(Access, Auth) of
|
||||
case check_access(Command, Access, Auth) of
|
||||
true ->
|
||||
check_access_command(Commands, Command, ArgumentRestrictions,
|
||||
Method, Arguments);
|
||||
@ -399,29 +461,48 @@ check_access_commands(AccessCommands, Auth, Method, Command, Arguments) ->
|
||||
L when is_list(L) -> ok
|
||||
end.
|
||||
|
||||
-spec check_auth(noauth) -> noauth_provided;
|
||||
({binary(), binary(), binary()}) -> {ok, binary(), binary()}.
|
||||
-spec check_auth(ejabberd_commands(), noauth) -> noauth_provided;
|
||||
(ejabberd_commands(),
|
||||
{binary(), binary(), binary(), boolean()}) ->
|
||||
{ok, binary(), binary()}.
|
||||
|
||||
check_auth(noauth) ->
|
||||
check_auth(_Command, noauth) ->
|
||||
no_auth_provided;
|
||||
check_auth({User, Server, Password}) ->
|
||||
check_auth(Command, {User, Server, {oauth, Token}, _}) ->
|
||||
Scope = erlang:atom_to_binary(Command#ejabberd_commands.name, utf8),
|
||||
case ejabberd_oauth:check_token(User, Server, Scope, Token) of
|
||||
true ->
|
||||
{ok, User, Server};
|
||||
false ->
|
||||
throw({error, invalid_account_data})
|
||||
end;
|
||||
check_auth(_Command, {User, Server, Password, _}) when is_binary(Password) ->
|
||||
%% Check the account exists and password is valid
|
||||
case ejabberd_auth:check_password(User, Server, Password) of
|
||||
true -> {ok, User, Server};
|
||||
_ -> throw({error, invalid_account_data})
|
||||
end.
|
||||
|
||||
check_access(all, _) ->
|
||||
check_access(Command, all, _)
|
||||
when Command#ejabberd_commands.policy == open ->
|
||||
true;
|
||||
check_access(Access, Auth) ->
|
||||
case check_auth(Auth) of
|
||||
check_access(_Command, _Access, admin) ->
|
||||
true;
|
||||
check_access(_Command, _Access, {_User, _Server, _, true}) ->
|
||||
false;
|
||||
check_access(Command, Access, Auth)
|
||||
when Command#ejabberd_commands.policy == open;
|
||||
Command#ejabberd_commands.policy == user ->
|
||||
case check_auth(Command, Auth) of
|
||||
{ok, User, Server} ->
|
||||
check_access(Access, User, Server);
|
||||
check_access2(Access, User, Server);
|
||||
_ ->
|
||||
false
|
||||
end.
|
||||
end;
|
||||
check_access(_Command, _Access, _Auth) ->
|
||||
false.
|
||||
|
||||
check_access(Access, User, Server) ->
|
||||
check_access2(Access, User, Server) ->
|
||||
%% Check this user has access permission
|
||||
case acl:match_rule(Server, Access, jlib:make_jid(User, Server, <<"">>)) of
|
||||
allow -> true;
|
||||
@ -452,3 +533,71 @@ tag_arguments(ArgsDefs, Args) ->
|
||||
end,
|
||||
ArgsDefs,
|
||||
Args).
|
||||
|
||||
|
||||
get_access_commands(undefined) ->
|
||||
Cmds = get_commands(),
|
||||
[{all, Cmds, []}];
|
||||
get_access_commands(AccessCommands) ->
|
||||
AccessCommands.
|
||||
|
||||
get_commands() ->
|
||||
Opts = ejabberd_config:get_option(
|
||||
commands,
|
||||
fun(V) when is_list(V) -> V end,
|
||||
[]),
|
||||
CommandsList = list_commands_policy(),
|
||||
OpenCmds = [N || {N, _, _, open} <- CommandsList],
|
||||
RestrictedCmds = [N || {N, _, _, restricted} <- CommandsList],
|
||||
AdminCmds = [N || {N, _, _, admin} <- CommandsList],
|
||||
UserCmds = [N || {N, _, _, user} <- CommandsList],
|
||||
Cmds =
|
||||
lists:foldl(
|
||||
fun({add_commands, L}, Acc) ->
|
||||
Cmds = case L of
|
||||
open -> OpenCmds;
|
||||
restricted -> RestrictedCmds;
|
||||
admin -> AdminCmds;
|
||||
user -> UserCmds;
|
||||
_ when is_list(L) -> L
|
||||
end,
|
||||
lists:usort(Cmds ++ Acc);
|
||||
({remove_commands, L}, Acc) ->
|
||||
Cmds = case L of
|
||||
open -> OpenCmds;
|
||||
restricted -> RestrictedCmds;
|
||||
admin -> AdminCmds;
|
||||
user -> UserCmds;
|
||||
_ when is_list(L) -> L
|
||||
end,
|
||||
Acc -- Cmds;
|
||||
(_, Acc) -> Acc
|
||||
end, AdminCmds ++ UserCmds, Opts),
|
||||
Cmds.
|
||||
|
||||
is_admin(_Name, noauth) ->
|
||||
false;
|
||||
is_admin(_Name, admin) ->
|
||||
true;
|
||||
is_admin(_Name, {_User, _Server, _, false}) ->
|
||||
false;
|
||||
is_admin(Name, {User, Server, _, true} = Auth) ->
|
||||
AdminAccess = ejabberd_config:get_option(
|
||||
commands_admin_access,
|
||||
fun(A) when is_atom(A) -> A end,
|
||||
none),
|
||||
case acl:match_rule(Server, AdminAccess,
|
||||
jlib:make_jid(User, Server, <<"">>)) of
|
||||
allow ->
|
||||
case catch check_auth(get_command_definition(Name), Auth) of
|
||||
{ok, _, _} -> true;
|
||||
_ -> false
|
||||
end;
|
||||
deny -> false
|
||||
end.
|
||||
|
||||
opt_type(commands_admin_access) ->
|
||||
fun(A) when is_atom(A) -> A end;
|
||||
opt_type(commands) ->
|
||||
fun(V) when is_list(V) -> V end;
|
||||
opt_type(_) -> [commands, commands_admin_access].
|
||||
|
@ -211,7 +211,7 @@ process(Args) ->
|
||||
process2(["--auth", User, Server, Pass | Args], AccessCommands) ->
|
||||
process2(Args, {list_to_binary(User), list_to_binary(Server), list_to_binary(Pass)}, AccessCommands);
|
||||
process2(Args, AccessCommands) ->
|
||||
process2(Args, noauth, AccessCommands).
|
||||
process2(Args, admin, AccessCommands).
|
||||
|
||||
process2(Args, Auth, AccessCommands) ->
|
||||
case try_run_ctp(Args, Auth, AccessCommands) of
|
||||
@ -283,7 +283,7 @@ call_command([CmdString | Args], Auth, AccessCommands) ->
|
||||
CmdStringU = ejabberd_regexp:greplace(
|
||||
list_to_binary(CmdString), <<"-">>, <<"_">>),
|
||||
Command = list_to_atom(binary_to_list(CmdStringU)),
|
||||
case ejabberd_commands:get_command_format(Command) of
|
||||
case ejabberd_commands:get_command_format(Command, Auth) of
|
||||
{error, command_unknown} ->
|
||||
{error, command_unknown};
|
||||
{ArgsFormat, ResultFormat} ->
|
||||
@ -412,7 +412,8 @@ get_list_commands() ->
|
||||
end.
|
||||
|
||||
%% Return: {string(), [string()], string()}
|
||||
tuple_command_help({Name, Args, Desc}) ->
|
||||
tuple_command_help({Name, _Args, Desc}) ->
|
||||
{Args, _} = ejabberd_commands:get_command_format(Name, admin),
|
||||
Arguments = [atom_to_list(ArgN) || {ArgN, _ArgF} <- Args],
|
||||
Prepend = case is_supported_args(Args) of
|
||||
true -> "";
|
||||
@ -723,12 +724,13 @@ print_usage_command(Cmd, C, MaxC, ShCode) ->
|
||||
tags = TagsAtoms,
|
||||
desc = Desc,
|
||||
longdesc = LongDesc,
|
||||
args = ArgsDef,
|
||||
result = ResultDef} = C,
|
||||
|
||||
NameFmt = [" ", ?B("Command Name"), ": ", Cmd, "\n"],
|
||||
|
||||
%% Initial indentation of result is 13 = length(" Arguments: ")
|
||||
{ArgsDef, _} = ejabberd_commands:get_command_format(
|
||||
C#ejabberd_commands.name, admin),
|
||||
Args = [format_usage_ctype(ArgDef, 13) || ArgDef <- ArgsDef],
|
||||
ArgsMargin = lists:duplicate(13, $\s),
|
||||
ArgsListFmt = case Args of
|
||||
|
@ -766,6 +766,9 @@ parse_auth(<<"Basic ", Auth64/binary>>) ->
|
||||
{User, <<$:, Pass/binary>>} = erlang:split_binary(Auth, Pos-1),
|
||||
{User, Pass}
|
||||
end;
|
||||
parse_auth(<<"Bearer ", SToken/binary>>) ->
|
||||
Token = str:strip(SToken),
|
||||
{oauth, Token, []};
|
||||
parse_auth(<<_/binary>>) -> undefined.
|
||||
|
||||
parse_urlencoded(S) ->
|
||||
|
473
src/ejabberd_oauth.erl
Normal file
473
src/ejabberd_oauth.erl
Normal file
File diff suppressed because one or more lines are too long
@ -198,33 +198,35 @@ socket_type() -> raw.
|
||||
process(_, #request{method = 'POST', data = Data, opts = Opts}) ->
|
||||
AccessCommandsOpts = gen_mod:get_opt(access_commands, Opts,
|
||||
fun(L) when is_list(L) -> L end,
|
||||
[]),
|
||||
AccessCommands = lists:flatmap(
|
||||
fun({Ac, AcOpts}) ->
|
||||
Commands = gen_mod:get_opt(
|
||||
commands, AcOpts,
|
||||
fun(A) when is_atom(A) ->
|
||||
A;
|
||||
(L) when is_list(L) ->
|
||||
true = lists:all(
|
||||
fun is_atom/1,
|
||||
L),
|
||||
L
|
||||
end, all),
|
||||
CommOpts = gen_mod:get_opt(
|
||||
options, AcOpts,
|
||||
fun(L) when is_list(L) -> L end,
|
||||
[]),
|
||||
[{Ac, Commands, CommOpts}];
|
||||
(Wrong) ->
|
||||
?WARNING_MSG("wrong options format for ~p: ~p",
|
||||
[?MODULE, Wrong]),
|
||||
[]
|
||||
end, AccessCommandsOpts),
|
||||
GetAuth = case [ACom || {Ac, _, _} = ACom <- AccessCommands, Ac /= all] of
|
||||
[] -> false;
|
||||
_ -> true
|
||||
end,
|
||||
undefined),
|
||||
AccessCommands =
|
||||
case AccessCommandsOpts of
|
||||
undefined -> undefined;
|
||||
_ ->
|
||||
lists:flatmap(
|
||||
fun({Ac, AcOpts}) ->
|
||||
Commands = gen_mod:get_opt(
|
||||
commands, AcOpts,
|
||||
fun(A) when is_atom(A) ->
|
||||
A;
|
||||
(L) when is_list(L) ->
|
||||
true = lists:all(
|
||||
fun is_atom/1,
|
||||
L),
|
||||
L
|
||||
end, all),
|
||||
CommOpts = gen_mod:get_opt(
|
||||
options, AcOpts,
|
||||
fun(L) when is_list(L) -> L end,
|
||||
[]),
|
||||
[{Ac, Commands, CommOpts}];
|
||||
(Wrong) ->
|
||||
?WARNING_MSG("wrong options format for ~p: ~p",
|
||||
[?MODULE, Wrong]),
|
||||
[]
|
||||
end, AccessCommandsOpts)
|
||||
end,
|
||||
GetAuth = true,
|
||||
State = #state{access_commands = AccessCommands, get_auth = GetAuth},
|
||||
case xml_stream:parse_element(Data) of
|
||||
{error, _} ->
|
||||
@ -257,17 +259,22 @@ process(_, _) ->
|
||||
%% -----------------------------
|
||||
|
||||
get_auth(AuthList) ->
|
||||
[User, Server, Password] = try get_attrs([user, server,
|
||||
password],
|
||||
AuthList)
|
||||
of
|
||||
[U, S, P] -> [U, S, P]
|
||||
catch
|
||||
exit:{attribute_not_found, Attr, _} ->
|
||||
throw({error, missing_auth_arguments,
|
||||
Attr})
|
||||
end,
|
||||
{User, Server, Password}.
|
||||
Admin =
|
||||
case lists:keysearch(admin, 1, AuthList) of
|
||||
{value, {admin, true}} -> true;
|
||||
_ -> false
|
||||
end,
|
||||
try get_attrs([user, server, token], AuthList) of
|
||||
[U, S, T] -> {U, S, {oauth, T}, Admin}
|
||||
catch
|
||||
exit:{attribute_not_found, _Attr, _} ->
|
||||
try get_attrs([user, server, password], AuthList) of
|
||||
[U, S, P] -> {U, S, P, Admin}
|
||||
catch
|
||||
exit:{attribute_not_found, Attr, _} ->
|
||||
throw({error, missing_auth_arguments, Attr})
|
||||
end
|
||||
end.
|
||||
|
||||
%% -----------------------------
|
||||
%% Handlers
|
||||
@ -297,9 +304,9 @@ handler(#state{get_auth = true, auth = noauth} = State,
|
||||
{call, Method,
|
||||
[{struct, AuthList} | Arguments] = AllArgs}) ->
|
||||
try get_auth(AuthList) of
|
||||
Auth ->
|
||||
handler(State#state{get_auth = false, auth = Auth},
|
||||
{call, Method, Arguments})
|
||||
Auth ->
|
||||
handler(State#state{get_auth = false, auth = Auth},
|
||||
{call, Method, Arguments})
|
||||
catch
|
||||
{error, missing_auth_arguments, _Attr} ->
|
||||
handler(State#state{get_auth = false, auth = noauth},
|
||||
@ -328,7 +335,7 @@ handler(State, {call, Command, []}) ->
|
||||
handler(State, {call, Command, [{struct, []}]});
|
||||
handler(State,
|
||||
{call, Command, [{struct, AttrL}]} = Payload) ->
|
||||
case ejabberd_commands:get_command_format(Command) of
|
||||
case ejabberd_commands:get_command_format(Command, State#state.auth) of
|
||||
{error, command_unknown} ->
|
||||
build_fault_response(-112, "Unknown call: ~p",
|
||||
[Payload]);
|
||||
|
392
src/mod_http_api.erl
Normal file
392
src/mod_http_api.erl
Normal file
@ -0,0 +1,392 @@
|
||||
%%%----------------------------------------------------------------------
|
||||
%%% File : mod_http_api.erl
|
||||
%%% Author : Christophe romain <christophe.romain@process-one.net>
|
||||
%%% Purpose : Implements REST API for ejabberd using JSON data
|
||||
%%% Created : 15 Sep 2014 by Christophe Romain <christophe.romain@process-one.net>
|
||||
%%%
|
||||
%%%
|
||||
%%% ejabberd, Copyright (C) 2002-2015 ProcessOne
|
||||
%%%
|
||||
%%% This program is free software; you can redistribute it and/or
|
||||
%%% modify it under the terms of the GNU General Public License as
|
||||
%%% published by the Free Software Foundation; either version 2 of the
|
||||
%%% License, or (at your option) any later version.
|
||||
%%%
|
||||
%%% This program is distributed in the hope that it will be useful,
|
||||
%%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
%%% General Public License for more details.
|
||||
%%%
|
||||
%%% You should have received a copy of the GNU General Public License along
|
||||
%%% with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
%%%
|
||||
%%%----------------------------------------------------------------------
|
||||
|
||||
%% Example config:
|
||||
%%
|
||||
%% in ejabberd_http listener
|
||||
%% request_handlers:
|
||||
%% "/api": mod_http_api
|
||||
%%
|
||||
%% Access rights are defined with:
|
||||
%% commands_admin_access: configure
|
||||
%% commands:
|
||||
%% - add_commands: user
|
||||
%%
|
||||
%%
|
||||
%% add_commands allow exporting a class of commands, from
|
||||
%% open: methods is not risky and can be called by without any access check
|
||||
%% restricted (default): the same, but will appear only in ejabberdctl list.
|
||||
%% admin – auth is required with XMLRPC and HTTP API and checked for admin priviledges, works as usual in ejabberdctl.
|
||||
%% user - can be used through XMLRPC and HTTP API, even by user. Only admin can use the commands for other users.
|
||||
%%
|
||||
%% Then to perform an action, send a POST request to the following URL:
|
||||
%% http://localhost:5280/api/<call_name>
|
||||
|
||||
-module(mod_http_api).
|
||||
|
||||
-author('cromain@process-one.net').
|
||||
|
||||
-behaviour(gen_mod).
|
||||
|
||||
-export([start/2, stop/1, process/2, mod_opt_type/1]).
|
||||
|
||||
-include("ejabberd.hrl").
|
||||
-include("jlib.hrl").
|
||||
-include("logger.hrl").
|
||||
-include("ejabberd_http.hrl").
|
||||
|
||||
-define(CT_PLAIN,
|
||||
{<<"Content-Type">>, <<"text/plain">>}).
|
||||
|
||||
-define(CT_XML,
|
||||
{<<"Content-Type">>, <<"text/xml; charset=utf-8">>}).
|
||||
|
||||
-define(CT_JSON,
|
||||
{<<"Content-Type">>, <<"application/json">>}).
|
||||
|
||||
-define(AC_ALLOW_ORIGIN,
|
||||
{<<"Access-Control-Allow-Origin">>, <<"*">>}).
|
||||
|
||||
-define(AC_ALLOW_METHODS,
|
||||
{<<"Access-Control-Allow-Methods">>,
|
||||
<<"GET, POST, OPTIONS">>}).
|
||||
|
||||
-define(AC_ALLOW_HEADERS,
|
||||
{<<"Access-Control-Allow-Headers">>,
|
||||
<<"Content-Type">>}).
|
||||
|
||||
-define(AC_MAX_AGE,
|
||||
{<<"Access-Control-Max-Age">>, <<"86400">>}).
|
||||
|
||||
-define(OPTIONS_HEADER,
|
||||
[?CT_PLAIN, ?AC_ALLOW_ORIGIN, ?AC_ALLOW_METHODS,
|
||||
?AC_ALLOW_HEADERS, ?AC_MAX_AGE]).
|
||||
|
||||
-define(HEADER(CType),
|
||||
[CType, ?AC_ALLOW_ORIGIN, ?AC_ALLOW_HEADERS]).
|
||||
|
||||
%% -------------------
|
||||
%% Module control
|
||||
%% -------------------
|
||||
|
||||
start(_Host, _Opts) ->
|
||||
ok.
|
||||
|
||||
stop(_Host) ->
|
||||
ok.
|
||||
|
||||
|
||||
%% ----------
|
||||
%% basic auth
|
||||
%% ----------
|
||||
|
||||
check_permissions(#request{auth = HTTPAuth, headers = Headers}, Command)
|
||||
when HTTPAuth /= undefined ->
|
||||
case catch binary_to_existing_atom(Command, utf8) of
|
||||
Call when is_atom(Call) ->
|
||||
Admin =
|
||||
case lists:keysearch(<<"X-Admin">>, 1, Headers) of
|
||||
{value, {_, <<"true">>}} -> true;
|
||||
_ -> false
|
||||
end,
|
||||
Auth =
|
||||
case HTTPAuth of
|
||||
{SJID, Pass} ->
|
||||
case jlib:string_to_jid(SJID) of
|
||||
#jid{user = User, server = Server} ->
|
||||
case ejabberd_auth:check_password(User, Server, Pass) of
|
||||
true -> {ok, {User, Server, Pass, Admin}};
|
||||
false -> false
|
||||
end;
|
||||
_ ->
|
||||
false
|
||||
end;
|
||||
{oauth, Token, _} ->
|
||||
case ejabberd_oauth:check_token(Command, Token) of
|
||||
{ok, User, Server} ->
|
||||
{ok, {User, Server, {oauth, Token}, Admin}};
|
||||
false ->
|
||||
false
|
||||
end;
|
||||
_ ->
|
||||
false
|
||||
end,
|
||||
case Auth of
|
||||
{ok, A} -> {allowed, Call, A};
|
||||
_ -> unauthorized_response()
|
||||
end;
|
||||
_ ->
|
||||
unauthorized_response()
|
||||
end;
|
||||
check_permissions(_, _Command) ->
|
||||
unauthorized_response().
|
||||
|
||||
%% ------------------
|
||||
%% command processing
|
||||
%% ------------------
|
||||
|
||||
process(_, #request{method = 'POST', data = <<>>}) ->
|
||||
?DEBUG("Bad Request: no data", []),
|
||||
badrequest_response();
|
||||
process([Call], #request{method = 'POST', data = Data, ip = IP} = Req) ->
|
||||
try
|
||||
Args = case jiffy:decode(Data) of
|
||||
List when is_list(List) -> List;
|
||||
{List} when is_list(List) -> List;
|
||||
Other -> [Other]
|
||||
end,
|
||||
log(Call, Args, IP),
|
||||
case check_permissions(Req, Call) of
|
||||
{allowed, Cmd, Auth} ->
|
||||
{Code, Result} = handle(Cmd, Auth, Args),
|
||||
json_response(Code, jiffy:encode(Result));
|
||||
ErrorResponse ->
|
||||
ErrorResponse
|
||||
end
|
||||
catch _:Error ->
|
||||
?DEBUG("Bad Request: ~p", [Error]),
|
||||
badrequest_response()
|
||||
end;
|
||||
process([Call], #request{method = 'GET', q = Data, ip = IP} = Req) ->
|
||||
try
|
||||
Args = case Data of
|
||||
[{nokey, <<>>}] -> [];
|
||||
_ -> Data
|
||||
end,
|
||||
log(Call, Args, IP),
|
||||
case check_permissions(Req, Call) of
|
||||
{allowed, Cmd, Auth} ->
|
||||
{Code, Result} = handle(Cmd, Auth, Args),
|
||||
json_response(Code, jiffy:encode(Result));
|
||||
ErrorResponse ->
|
||||
ErrorResponse
|
||||
end
|
||||
catch _:Error ->
|
||||
?DEBUG("Bad Request: ~p", [Error]),
|
||||
badrequest_response()
|
||||
end;
|
||||
process([], #request{method = 'OPTIONS', data = <<>>}) ->
|
||||
{200, ?OPTIONS_HEADER, []};
|
||||
process(_Path, Request) ->
|
||||
?DEBUG("Bad Request: no handler ~p", [Request]),
|
||||
badrequest_response().
|
||||
|
||||
%% ----------------
|
||||
%% command handlers
|
||||
%% ----------------
|
||||
|
||||
% generic ejabberd command handler
|
||||
handle(Call, Auth, Args) when is_atom(Call), is_list(Args) ->
|
||||
case ejabberd_commands:get_command_format(Call, Auth) of
|
||||
{ArgsSpec, _} when is_list(ArgsSpec) ->
|
||||
Args2 = [{jlib:binary_to_atom(Key), Value} || {Key, Value} <- Args],
|
||||
Spec = lists:foldr(
|
||||
fun ({Key, binary}, Acc) ->
|
||||
[{Key, <<>>}|Acc];
|
||||
({Key, string}, Acc) ->
|
||||
[{Key, <<>>}|Acc];
|
||||
({Key, integer}, Acc) ->
|
||||
[{Key, 0}|Acc];
|
||||
({Key, {list, _}}, Acc) ->
|
||||
[{Key, []}|Acc];
|
||||
({Key, atom}, Acc) ->
|
||||
[{Key, undefined}|Acc]
|
||||
end, [], ArgsSpec),
|
||||
handle2(Call, Auth, match(Args2, Spec));
|
||||
{error, Msg} ->
|
||||
{400, Msg};
|
||||
_Error ->
|
||||
{400, <<"Error">>}
|
||||
end.
|
||||
|
||||
handle2(Call, Auth, Args) when is_atom(Call), is_list(Args) ->
|
||||
{ArgsF, _ResultF} = ejabberd_commands:get_command_format(Call, Auth),
|
||||
ArgsFormatted = format_args(Args, ArgsF),
|
||||
case ejabberd_command(Auth, Call, ArgsFormatted, 400) of
|
||||
0 -> {200, <<"OK">>};
|
||||
1 -> {500, <<"500 Internal server error">>};
|
||||
400 -> {400, <<"400 Bad Request">>};
|
||||
404 -> {404, <<"404 Not found">>};
|
||||
Res -> format_command_result(Call, Auth, Res)
|
||||
end.
|
||||
|
||||
get_elem_delete(A, L) ->
|
||||
case proplists:get_all_values(A, L) of
|
||||
[Value] -> {Value, proplists:delete(A, L)};
|
||||
[_, _ | _] ->
|
||||
%% Crash reporting the error
|
||||
exit({duplicated_attribute, A, L});
|
||||
[] ->
|
||||
%% Report the error and then force a crash
|
||||
exit({attribute_not_found, A, L})
|
||||
end.
|
||||
|
||||
format_args(Args, ArgsFormat) ->
|
||||
{ArgsRemaining, R} = lists:foldl(fun ({ArgName,
|
||||
ArgFormat},
|
||||
{Args1, Res}) ->
|
||||
{ArgValue, Args2} =
|
||||
get_elem_delete(ArgName,
|
||||
Args1),
|
||||
Formatted = format_arg(ArgValue,
|
||||
ArgFormat),
|
||||
{Args2, Res ++ [Formatted]}
|
||||
end,
|
||||
{Args, []}, ArgsFormat),
|
||||
case ArgsRemaining of
|
||||
[] -> R;
|
||||
L when is_list(L) -> exit({additional_unused_args, L})
|
||||
end.
|
||||
|
||||
format_arg({array, Elements},
|
||||
{list, {ElementDefName, ElementDefFormat}})
|
||||
when is_list(Elements) ->
|
||||
lists:map(fun ({struct, [{ElementName, ElementValue}]}) when
|
||||
ElementDefName == ElementName ->
|
||||
format_arg(ElementValue, ElementDefFormat)
|
||||
end,
|
||||
Elements);
|
||||
format_arg({array, [{struct, Elements}]},
|
||||
{list, {ElementDefName, ElementDefFormat}})
|
||||
when is_list(Elements) ->
|
||||
lists:map(fun ({ElementName, ElementValue}) ->
|
||||
true = ElementDefName == ElementName,
|
||||
format_arg(ElementValue, ElementDefFormat)
|
||||
end,
|
||||
Elements);
|
||||
format_arg({array, [{struct, Elements}]},
|
||||
{tuple, ElementsDef})
|
||||
when is_list(Elements) ->
|
||||
FormattedList = format_args(Elements, ElementsDef),
|
||||
list_to_tuple(FormattedList);
|
||||
format_arg({array, Elements}, {list, ElementsDef})
|
||||
when is_list(Elements) and is_atom(ElementsDef) ->
|
||||
[format_arg(Element, ElementsDef)
|
||||
|| Element <- Elements];
|
||||
format_arg(Arg, integer) when is_integer(Arg) -> Arg;
|
||||
format_arg(Arg, binary) when is_list(Arg) -> process_unicode_codepoints(Arg);
|
||||
format_arg(Arg, binary) when is_binary(Arg) -> Arg;
|
||||
format_arg(Arg, string) when is_list(Arg) -> process_unicode_codepoints(Arg);
|
||||
format_arg(Arg, string) when is_binary(Arg) -> Arg;
|
||||
format_arg(undefined, binary) -> <<>>;
|
||||
format_arg(undefined, string) -> <<>>;
|
||||
format_arg(Arg, Format) ->
|
||||
?ERROR_MSG("don't know how to format Arg ~p for format ~p", [Arg, Format]),
|
||||
error.
|
||||
|
||||
process_unicode_codepoints(Str) ->
|
||||
iolist_to_binary(lists:map(fun(X) when X > 255 -> unicode:characters_to_binary([X]);
|
||||
(Y) -> Y
|
||||
end, Str)).
|
||||
|
||||
%% ----------------
|
||||
%% internal helpers
|
||||
%% ----------------
|
||||
|
||||
match(Args, Spec) ->
|
||||
[{Key, proplists:get_value(Key, Args, Default)} || {Key, Default} <- Spec].
|
||||
|
||||
ejabberd_command(Auth, Cmd, Args, Default) ->
|
||||
case catch ejabberd_commands:execute_command(undefined, Auth, Cmd, Args) of
|
||||
{'EXIT', _} -> Default;
|
||||
{error, _} -> Default;
|
||||
Result -> Result
|
||||
end.
|
||||
|
||||
format_command_result(Cmd, Auth, Result) ->
|
||||
{_, ResultFormat} = ejabberd_commands:get_command_format(Cmd, Auth),
|
||||
case {ResultFormat, Result} of
|
||||
{{_, rescode}, V} when V == true; V == ok ->
|
||||
{200, <<"">>};
|
||||
{{_, rescode}, _} ->
|
||||
{500, <<"">>};
|
||||
{{_, restuple}, {V1, Text1}} when V1 == true; V1 == ok ->
|
||||
{200, iolist_to_binary(Text1)};
|
||||
{{_, restuple}, {_, Text2}} ->
|
||||
{500, iolist_to_binary(Text2)};
|
||||
{{_, {list, _}}, _V} ->
|
||||
{_, L} = format_result(Result, ResultFormat),
|
||||
{200, L};
|
||||
{{_, {tuple, _}}, _V} ->
|
||||
{_, T} = format_result(Result, ResultFormat),
|
||||
{200, T};
|
||||
_ ->
|
||||
{200, {[format_result(Result, ResultFormat)]}}
|
||||
end.
|
||||
|
||||
format_result(Atom, {Name, atom}) ->
|
||||
{jlib:atom_to_binary(Name), jlib:atom_to_binary(Atom)};
|
||||
|
||||
format_result(Int, {Name, integer}) ->
|
||||
{jlib:atom_to_binary(Name), Int};
|
||||
|
||||
format_result(String, {Name, string}) ->
|
||||
{jlib:atom_to_binary(Name), iolist_to_binary(String)};
|
||||
|
||||
format_result(Code, {Name, rescode}) ->
|
||||
{jlib:atom_to_binary(Name), Code == true orelse Code == ok};
|
||||
|
||||
format_result({Code, Text}, {Name, restuple}) ->
|
||||
{jlib:atom_to_binary(Name),
|
||||
{[{<<"res">>, Code == true orelse Code == ok},
|
||||
{<<"text">>, iolist_to_binary(Text)}]}};
|
||||
|
||||
format_result(Els, {Name, {list, {_, {tuple, [{_, atom}, _]}} = Fmt}}) ->
|
||||
{jlib:atom_to_binary(Name), {[format_result(El, Fmt) || El <- Els]}};
|
||||
|
||||
format_result(Els, {Name, {list, Def}}) ->
|
||||
{jlib:atom_to_binary(Name), [element(2, format_result(El, Def)) || El <- Els]};
|
||||
|
||||
format_result(Tuple, {_Name, {tuple, [{_, atom}, ValFmt]}}) ->
|
||||
{Name2, Val} = Tuple,
|
||||
{_, Val2} = format_result(Val, ValFmt),
|
||||
{jlib:atom_to_binary(Name2), Val2};
|
||||
|
||||
format_result(Tuple, {Name, {tuple, Def}}) ->
|
||||
Els = lists:zip(tuple_to_list(Tuple), Def),
|
||||
{jlib:atom_to_binary(Name), {[format_result(El, ElDef) || {El, ElDef} <- Els]}};
|
||||
|
||||
format_result(404, {_Name, _}) ->
|
||||
"not_found".
|
||||
|
||||
unauthorized_response() ->
|
||||
{401, ?HEADER(?CT_XML),
|
||||
#xmlel{name = <<"h1">>, attrs = [],
|
||||
children = [{xmlcdata, <<"401 Unauthorized">>}]}}.
|
||||
|
||||
badrequest_response() ->
|
||||
{400, ?HEADER(?CT_XML),
|
||||
#xmlel{name = <<"h1">>, attrs = [],
|
||||
children = [{xmlcdata, <<"400 Bad Request">>}]}}.
|
||||
json_response(Code, Body) when is_integer(Code) ->
|
||||
{Code, ?HEADER(?CT_JSON), Body}.
|
||||
|
||||
log(Call, Args, {Addr, Port}) ->
|
||||
AddrS = jlib:ip_to_list({Addr, Port}),
|
||||
?INFO_MSG("Admin call ~s ~p from ~s:~p", [Call, Args, AddrS, Port]).
|
||||
|
||||
mod_opt_type(access) ->
|
||||
fun(Access) when is_atom(Access) -> Access end;
|
||||
mod_opt_type(_) -> [access].
|
@ -31,6 +31,7 @@
|
||||
{elixir, @elixir@}.
|
||||
{lager, @lager@}.
|
||||
{iconv, @iconv@}.
|
||||
{oauth, @oauth@}.
|
||||
|
||||
%% Version
|
||||
{vsn, "@PACKAGE_VERSION@"}.
|
||||
|
Loading…
Reference in New Issue
Block a user