diff --git a/apps/emqx_auth_http/etc/emqx_auth_http.conf b/apps/emqx_auth_http/etc/emqx_auth_http.conf index 86c4ac002..0df589169 100644 --- a/apps/emqx_auth_http/etc/emqx_auth_http.conf +++ b/apps/emqx_auth_http/etc/emqx_auth_http.conf @@ -101,8 +101,8 @@ auth.http.acl_req.params = access=%A,username=%u,clientid=%c,ipaddr=%a,topic=%t, ## -m: minute, e.g. '5m' for 5 minutes ## -s: second, e.g. '30s' for 30 seconds ## -## Default: 0 -## auth.http.request.timeout = 0 +## Default: 5s +## auth.http.request.timeout = 5s ## Connection time-out time, used during the initial request ## when the client is connecting to the server @@ -117,7 +117,7 @@ auth.http.acl_req.params = access=%A,username=%u,clientid=%c,ipaddr=%a,topic=%t, ## Value: integer ## ## Default: 3 -auth.http.request.retry_times = 3 +auth.http.request.retry_times = 5 ## The interval for re-sending the http request ## diff --git a/apps/emqx_auth_http/include/emqx_auth_http.hrl b/apps/emqx_auth_http/include/emqx_auth_http.hrl index 09e58e324..2bbe12827 100644 --- a/apps/emqx_auth_http/include/emqx_auth_http.hrl +++ b/apps/emqx_auth_http/include/emqx_auth_http.hrl @@ -1,7 +1,7 @@ -define(APP, emqx_auth_http). --record(http_request, {method = post, content_type, url, params, options = []}). +-record(http_request, {method = post, path, headers, params, request_timeout}). -record(auth_metrics, { success = 'client.auth.success', diff --git a/apps/emqx_auth_http/priv/emqx_auth_http.schema b/apps/emqx_auth_http/priv/emqx_auth_http.schema index e6a986344..4f4289db0 100644 --- a/apps/emqx_auth_http/priv/emqx_auth_http.schema +++ b/apps/emqx_auth_http/priv/emqx_auth_http.schema @@ -11,7 +11,7 @@ {mapping, "auth.http.auth_req.content_type", "emqx_auth_http.auth_req", [ {default, 'x-www-form-urlencoded'}, - {datatype, {enum, [json, 'x-www-form-urlencoded']}} + {datatype, {enum, ['json', 'x-www-form-urlencoded']}} ]}. {mapping, "auth.http.auth_req.params", "emqx_auth_http.auth_req", [ @@ -25,7 +25,7 @@ Params = cuttlefish:conf_get("auth.http.auth_req.params", Conf), [{url, Url}, {method, cuttlefish:conf_get("auth.http.auth_req.method", Conf)}, - {content_type, cuttlefish:conf_get("auth.http.auth_req.content_type", Conf)}, + {content_type, list_to_binary("application/" ++ atom_to_list(cuttlefish:conf_get("auth.http.auth_req.content_type", Conf)))}, {params, [list_to_tuple(string:tokens(S, "=")) || S <- string:tokens(Params, ",")]}] end end}. @@ -41,7 +41,7 @@ end}. {mapping, "auth.http.super_req.content_type", "emqx_auth_http.super_req", [ {default, 'x-www-form-urlencoded'}, - {datatype, {enum, [json, 'x-www-form-urlencoded']}} + {datatype, {enum, ['json', 'x-www-form-urlencoded']}} ]}. {mapping, "auth.http.super_req.params", "emqx_auth_http.super_req", [ @@ -53,7 +53,7 @@ end}. undefined -> cuttlefish:unset(); Url -> Params = cuttlefish:conf_get("auth.http.super_req.params", Conf), [{url, Url}, {method, cuttlefish:conf_get("auth.http.super_req.method", Conf)}, - {content_type, cuttlefish:conf_get("auth.http.super_req.content_type", Conf)}, + {content_type, list_to_binary("application/" ++ atom_to_list(cuttlefish:conf_get("auth.http.super_req.content_type", Conf)))}, {params, [list_to_tuple(string:tokens(S, "=")) || S <- string:tokens(Params, ",")]}] end end}. @@ -70,7 +70,7 @@ end}. {mapping, "auth.http.acl_req.content_type", "emqx_auth_http.acl_req", [ {default, 'x-www-form-urlencoded'}, - {datatype, {enum, [json, 'x-www-form-urlencoded']}} + {datatype, {enum, ['json', 'x-www-form-urlencoded']}} ]}. {mapping, "auth.http.acl_req.params", "emqx_auth_http.acl_req", [ @@ -81,34 +81,56 @@ end}. case cuttlefish:conf_get("auth.http.acl_req", Conf, undefined) of undefined -> cuttlefish:unset(); Url -> Params = cuttlefish:conf_get("auth.http.acl_req.params", Conf), - [{url, Url}, {method, cuttlefish:conf_get("auth.http.acl_req.method", Conf)}, - {content_type, cuttlefish:conf_get("auth.http.acl_req.content_type", Conf)}, + [{url, Url}, + {method, cuttlefish:conf_get("auth.http.acl_req.method", Conf)}, + {content_type, list_to_binary("application/" ++ atom_to_list(cuttlefish:conf_get("auth.http.acl_req.content_type", Conf)))}, {params, [list_to_tuple(string:tokens(S, "=")) || S <- string:tokens(Params, ",")]}] end end}. -{mapping, "auth.http.request.timeout", "emqx_auth_http.http_opts", [ - {default, 0}, +{mapping, "auth.http.request.timeout", "emqx_auth_http.request_timeout", [ + {default, "5s"}, {datatype, [integer, {duration, ms}]} ]}. -{mapping, "auth.http.request.connect_timeout", "emqx_auth_http.http_opts", [ +{mapping, "auth.http.pool_size", "emqx_auth_http.pool_opts", [ + {default, 8}, + {datatype, integer} +]}. + +{mapping, "auth.http.request.connect_timeout", "emqx_auth_http.pool_opts", [ + {default, "5s"}, {datatype, [integer, {duration, ms}]} ]}. -{mapping, "auth.http.ssl.cacertfile", "emqx_auth_http.http_opts", [ +{mapping, "auth.http.ssl.cacertfile", "emqx_auth_http.pool_opts", [ {datatype, string} ]}. -{mapping, "auth.http.ssl.certfile", "emqx_auth_http.http_opts", [ +{mapping, "auth.http.ssl.certfile", "emqx_auth_http.pool_opts", [ {datatype, string} ]}. -{mapping, "auth.http.ssl.keyfile", "emqx_auth_http.http_opts", [ +{mapping, "auth.http.ssl.keyfile", "emqx_auth_http.pool_opts", [ {datatype, string} ]}. -{translation, "emqx_auth_http.http_opts", fun(Conf) -> +{mapping, "auth.http.request.retry_times", "emqx_auth_http.pool_opts", [ + {default, 5}, + {datatype, integer} +]}. + +{mapping, "auth.http.request.retry_interval", "emqx_auth_http.pool_opts", [ + {default, "1s"}, + {datatype, {duration, ms}} +]}. + +{mapping, "auth.http.request.retry_backoff", "emqx_auth_http.pool_opts", [ + {default, 2.0}, + {datatype, float} +]}. + +{translation, "emqx_auth_http.pool_opts", fun(Conf) -> Filter = fun(L) -> [{K, V} || {K, V} <- L, V =/= undefined] end, InfinityFun = fun(0) -> infinity; (Duration) -> Duration @@ -116,8 +138,10 @@ end}. SslOpts = Filter([{cacertfile, cuttlefish:conf_get("auth.http.ssl.cacertfile", Conf, undefined)}, {certfile, cuttlefish:conf_get("auth.http.ssl.certfile", Conf, undefined)}, {keyfile, cuttlefish:conf_get("auth.http.ssl.keyfile", Conf, undefined)}]), - Opts = [{timeout, InfinityFun(cuttlefish:conf_get("auth.http.request.timeout", Conf))}, - {connect_timeout, InfinityFun(cuttlefish:conf_get("auth.http.request.connect_timeout", Conf, undefined))}], + Opts = [{pool_size, cuttlefish:conf_get("auth.http.pool_size", Conf)}, + {connect_timeout, InfinityFun(cuttlefish:conf_get("auth.http.request.connect_timeout", Conf))}, + {retry, cuttlefish:conf_get("auth.http.request.retry_times", Conf)}, + {retry_timeout, cuttlefish:conf_get("auth.http.request.retry_interval", Conf)}], case SslOpts of [] -> Filter(Opts); _ -> @@ -131,26 +155,6 @@ end}. end end}. -{mapping, "auth.http.request.retry_times", "emqx_auth_http.retry_opts", [ - {default, 3}, - {datatype, integer} -]}. - -{mapping, "auth.http.request.retry_interval", "emqx_auth_http.retry_opts", [ - {default, "1s"}, - {datatype, {duration, ms}} -]}. - -{mapping, "auth.http.request.retry_backoff", "emqx_auth_http.retry_opts", [ - {default, 2.0}, - {datatype, float} -]}. - -{translation, "emqx_auth_http.retry_opts", fun(Conf) -> - [{times, cuttlefish:conf_get("auth.http.request.retry_times", Conf)}, - {interval, cuttlefish:conf_get("auth.http.request.retry_interval", Conf)}, - {backoff, cuttlefish:conf_get("auth.http.request.retry_backoff", Conf)}] -end}. {mapping, "auth.http.header.$field", "emqx_auth_http.headers", [ {datatype, string} diff --git a/apps/emqx_auth_http/rebar.config b/apps/emqx_auth_http/rebar.config index 026e6fc9b..0b1959427 100644 --- a/apps/emqx_auth_http/rebar.config +++ b/apps/emqx_auth_http/rebar.config @@ -1,4 +1,8 @@ -{deps, []}. +{deps, + [{cowlib, {git, "https://github.com/ninenines/cowlib", {tag, "2.7.0"}}}, + {gun, {git, "https://github.com/emqx/gun", {tag, "1.3.4"}}}, + {gproc, {git, "https://github.com/uwiger/gproc", {tag, "0.8.0"}}} + ]}. {edoc_opts, [{preprocess, true}]}. {erl_opts, [warn_unused_vars, @@ -20,7 +24,7 @@ [{test, [{deps, [{emqx_ct_helpers, {git, "https://github.com/emqx/emqx-ct-helpers", {tag, "1.2.2"}}}, - {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.0"}}} + {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "v1.2.2"}}} ]} ]} ]}. diff --git a/apps/emqx_auth_http/src/emqx_acl_http.erl b/apps/emqx_auth_http/src/emqx_acl_http.erl index ebe415937..a6f60b465 100644 --- a/apps/emqx_auth_http/src/emqx_acl_http.erl +++ b/apps/emqx_auth_http/src/emqx_acl_http.erl @@ -24,7 +24,7 @@ -logger_header("[ACL http]"). -import(emqx_auth_http_cli, - [ request/8 + [ request/6 , feedvar/2 ]). @@ -48,18 +48,16 @@ check_acl(ClientInfo, PubSub, Topic, AclResult, State) -> do_check_acl(#{username := <<$$, _/binary>>}, _PubSub, _Topic, _AclResult, _Config) -> ok; -do_check_acl(ClientInfo, PubSub, Topic, _AclResult, #{acl_req := AclReq, - http_opts := HttpOpts, - retry_opts := RetryOpts, - headers := Headers}) -> +do_check_acl(ClientInfo, PubSub, Topic, _AclResult, #{acl_req := AclReq, + pool_name := PoolName}) -> ClientInfo1 = ClientInfo#{access => access(PubSub), topic => Topic}, - case check_acl_request(AclReq, ClientInfo1, Headers, HttpOpts, RetryOpts) of - {ok, 200, "ignore"} -> ok; + case check_acl_request(PoolName, AclReq, ClientInfo1) of + {ok, 200, <<"ignore">>} -> ok; {ok, 200, _Body} -> {stop, allow}; {ok, _Code, _Body} -> {stop, deny}; {error, Error} -> - ?LOG(error, "Request ACL url ~s, error: ~p", - [AclReq#http_request.url, Error]), + ?LOG(error, "Request ACL path ~s, error: ~p", + [AclReq#http_request.path, Error]), ok end. @@ -79,13 +77,12 @@ inc_metrics({stop, deny}) -> return_with(Fun, Result) -> Fun(Result), Result. -check_acl_request(#http_request{url = Url, - method = Method, - content_type = ContentType, - params = Params, - options = Options}, - ClientInfo, Headers, HttpOpts, RetryOpts) -> - request(Method, ContentType, Url, feedvar(Params, ClientInfo), Headers, HttpOpts, Options, RetryOpts). +check_acl_request(PoolName, #http_request{path = Path, + method = Method, + headers = Headers, + params = Params, + request_timeout = RequestTimeout}, ClientInfo) -> + request(PoolName, Method, Path, Headers, feedvar(Params, ClientInfo), RequestTimeout). access(subscribe) -> 1; access(publish) -> 2. diff --git a/apps/emqx_auth_http/src/emqx_auth_http.app.src b/apps/emqx_auth_http/src/emqx_auth_http.app.src index eaabdb37e..ce73e10ea 100644 --- a/apps/emqx_auth_http/src/emqx_auth_http.app.src +++ b/apps/emqx_auth_http/src/emqx_auth_http.app.src @@ -3,7 +3,7 @@ {vsn, "git"}, {modules, []}, {registered, [emqx_auth_http_sup]}, - {applications, [kernel,stdlib]}, + {applications, [kernel,stdlib,gproc,gun]}, {mod, {emqx_auth_http_app, []}}, {env, []}, {licenses, ["Apache-2.0"]}, diff --git a/apps/emqx_auth_http/src/emqx_auth_http.erl b/apps/emqx_auth_http/src/emqx_auth_http.erl index 54e41c989..20026d6ee 100644 --- a/apps/emqx_auth_http/src/emqx_auth_http.erl +++ b/apps/emqx_auth_http/src/emqx_auth_http.erl @@ -25,7 +25,7 @@ -logger_header("[Auth http]"). -import(emqx_auth_http_cli, - [ request/8 + [ request/6 , feedvar/2 ]). @@ -41,28 +41,26 @@ register_metrics() -> check(ClientInfo, AuthResult, #{auth_req := AuthReq, super_req := SuperReq, - http_opts := HttpOpts, - retry_opts := RetryOpts, - headers := Headers}) -> - case authenticate(AuthReq, ClientInfo, Headers, HttpOpts, RetryOpts) of - {ok, 200, "ignore"} -> + pool_name := PoolName}) -> + case authenticate(PoolName, AuthReq, ClientInfo) of + {ok, 200, <<"ignore">>} -> emqx_metrics:inc(?AUTH_METRICS(ignore)), ok; {ok, 200, Body} -> emqx_metrics:inc(?AUTH_METRICS(success)), - IsSuperuser = is_superuser(SuperReq, ClientInfo, Headers, HttpOpts, RetryOpts), + IsSuperuser = is_superuser(PoolName, SuperReq, ClientInfo), {stop, AuthResult#{is_superuser => IsSuperuser, auth_result => success, anonymous => false, mountpoint => mountpoint(Body, ClientInfo)}}; {ok, Code, _Body} -> - ?LOG(error, "Deny connection from url: ~s, response http code: ~p", - [AuthReq#http_request.url, Code]), + ?LOG(error, "Deny connection from path: ~s, response http code: ~p", + [AuthReq#http_request.path, Code]), emqx_metrics:inc(?AUTH_METRICS(failure)), {stop, AuthResult#{auth_result => http_to_connack_error(Code), anonymous => false}}; {error, Error} -> - ?LOG(error, "Request auth url: ~s, error: ~p", - [AuthReq#http_request.url, Error]), + ?LOG(error, "Request auth path: ~s, error: ~p", + [AuthReq#http_request.path, Error]), emqx_metrics:inc(?AUTH_METRICS(failure)), %%FIXME later: server_unavailable is not right. {stop, AuthResult#{auth_result => server_unavailable, @@ -75,32 +73,30 @@ description() -> "Authentication by HTTP API". %% Requests %%-------------------------------------------------------------------- -authenticate(#http_request{url = Url, - method = Method, - content_type = ContentType, - params = Params, - options = Options}, - ClientInfo, HttpHeaders, HttpOpts, RetryOpts) -> - request(Method, ContentType, Url, feedvar(Params, ClientInfo), HttpHeaders, HttpOpts, Options, RetryOpts). +authenticate(PoolName, #http_request{path = Path, + method = Method, + headers = Headers, + params = Params, + request_timeout = RequestTimeout}, ClientInfo) -> + request(PoolName, Method, Path, Headers, feedvar(Params, ClientInfo), RequestTimeout). --spec(is_superuser(maybe(#http_request{}), emqx_types:client(), list(), list(), list()) -> boolean()). -is_superuser(undefined, _ClientInfo, _HttpHeaders, _HttpOpts, _RetryOpts) -> +-spec(is_superuser(atom(), maybe(#http_request{}), emqx_types:client()) -> boolean()). +is_superuser(_PoolName, undefined, _ClientInfo) -> false; -is_superuser(#http_request{url = Url, - method = Method, - content_type = ContentType, - params = Params, - options = Options}, - ClientInfo, HttpHeaders, HttpOpts, RetryOpts) -> - case request(Method, ContentType, Url, feedvar(Params, ClientInfo), HttpHeaders, HttpOpts, Options, RetryOpts) of +is_superuser(PoolName, #http_request{path = Path, + method = Method, + headers = Headers, + params = Params, + request_timeout = RequestTimeout}, ClientInfo) -> + case request(PoolName, Method, Path, Headers, feedvar(Params, ClientInfo), RequestTimeout) of {ok, 200, _Body} -> true; {ok, _Code, _Body} -> false; - {error, Error} -> ?LOG(error, "Request superuser url ~s, error: ~p", [Url, Error]), + {error, Error} -> ?LOG(error, "Request superuser path ~s, error: ~p", [Path, Error]), false end. mountpoint(Body, #{mountpoint := Mountpoint}) -> - case emqx_json:safe_decode(iolist_to_binary(Body), [return_maps]) of + case emqx_json:safe_decode(Body, [return_maps]) of {error, _} -> Mountpoint; {ok, Json} when is_map(Json) -> maps:get(<<"mountpoint">>, Json, Mountpoint); diff --git a/apps/emqx_auth_http/src/emqx_auth_http_app.erl b/apps/emqx_auth_http/src/emqx_auth_http_app.erl index 51f6762ee..1d235ca2a 100644 --- a/apps/emqx_auth_http/src/emqx_auth_http_app.erl +++ b/apps/emqx_auth_http/src/emqx_auth_http_app.erl @@ -17,7 +17,6 @@ -module(emqx_auth_http_app). -behaviour(application). --behaviour(supervisor). -emqx_plugin(auth). @@ -33,37 +32,35 @@ %%-------------------------------------------------------------------- start(_StartType, _StartArgs) -> - with_env(auth_req, fun load_auth_hook/1), - with_env(acl_req, fun load_acl_hook/1), - supervisor:start_link({local, ?MODULE}, ?MODULE, []). + case translate_env() of + ok -> + {ok, PoolOpts} = application:get_env(?APP, pool_opts), + {ok, Sup} = emqx_http_client_sup:start_link(?APP, ssl(inet(PoolOpts))), + with_env(auth_req, fun load_auth_hook/1), + with_env(acl_req, fun load_acl_hook/1), + {ok, Sup}; + {error, Reason} -> + {error, Reason} + end. load_auth_hook(AuthReq) -> ok = emqx_auth_http:register_metrics(), SuperReq = r(application:get_env(?APP, super_req, undefined)), - HttpOpts = application:get_env(?APP, http_opts, []), - RetryOpts = application:get_env(?APP, retry_opts, []), - Headers = application:get_env(?APP, headers, []), Params = #{auth_req => AuthReq, super_req => SuperReq, - http_opts => HttpOpts, - retry_opts => maps:from_list(RetryOpts), - headers => Headers}, + pool_name => ?APP}, emqx:hook('client.authenticate', {emqx_auth_http, check, [Params]}). load_acl_hook(AclReq) -> ok = emqx_acl_http:register_metrics(), - HttpOpts = application:get_env(?APP, http_opts, []), - RetryOpts = application:get_env(?APP, retry_opts, []), - Headers = application:get_env(?APP, headers, []), - Params = #{acl_req => AclReq, - http_opts => HttpOpts, - retry_opts => maps:from_list(RetryOpts), - headers => Headers}, + Params = #{acl_req => AclReq, + pool_name => ?APP}, emqx:hook('client.check_acl', {emqx_acl_http, check_acl, [Params]}). stop(_State) -> emqx:unhook('client.authenticate', {emqx_auth_http, check}), - emqx:unhook('client.check_acl', {emqx_acl_http, check_acl}). + emqx:unhook('client.check_acl', {emqx_acl_http, check_acl}), + emqx_http_client_sup:stop_pool(?APP). %%-------------------------------------------------------------------- %% Dummy supervisor @@ -85,19 +82,66 @@ with_env(Par, Fun) -> r(undefined) -> undefined; r(Config) -> + Headers = application:get_env(?APP, headers, []), Method = proplists:get_value(method, Config, post), - ContentType = proplists:get_value(content_type, Config, 'x-www-form-urlencoded'), - Url = proplists:get_value(url, Config), + Path = proplists:get_value(path, Config), + NewHeaders = [{<<"content_type">>, proplists:get_value(content_type, Config, <<"application/x-www-form-urlencoded">>)} | Headers], Params = proplists:get_value(params, Config), - #http_request{method = Method, content_type = ContentType, url = Url, params = Params, options = inet(Url)}. + {ok, RequestTimeout} = application:get_env(?APP, request_timeout), + #http_request{method = Method, path = Path, headers = NewHeaders, params = Params, request_timeout = RequestTimeout}. -inet(Url) -> - case uri_string:parse(Url) of - #{host := Host} -> - case inet:parse_address(Host) of - {ok, Ip} when tuple_size(Ip) =:= 8 -> - [{ipv6_host_with_brackets, true}, {socket_opts, [{ipfamily, inet6}]}]; - _ -> [] - end; - _ -> [] +inet(PoolOpts) -> + case proplists:get_value(host, PoolOpts) of + Host when tuple_size(Host) =:= 8 -> + TransOpts = proplists:get_value(transport_opts, PoolOpts, []), + NewPoolOpts = proplists:delete(transport_opts, PoolOpts), + [{transport_opts, [inet6 | TransOpts]} | NewPoolOpts]; + _ -> + PoolOpts end. + +ssl(PoolOpts) -> + case proplists:get_value(ssl, PoolOpts, []) of + [] -> + PoolOpts; + SSLOpts -> + TransOpts = proplists:get_value(transport_opts, PoolOpts, []), + NewPoolOpts = proplists:delete(transport_opts, PoolOpts), + [{transport_opts, SSLOpts ++ TransOpts}, {transport, ssl} | NewPoolOpts] + end. + +translate_env() -> + URLs = lists:foldl(fun(Name, Acc) -> + case application:get_env(?APP, Name, []) of + [] -> Acc; + Env -> + URL = proplists:get_value(url, Env), + #{host := Host0, + port := Port, + path := Path} = uri_string:parse(list_to_binary(URL)), + {ok, Host} = inet:parse_address(binary_to_list(Host0)), + [{Name, {Host, Port, binary_to_list(Path)}} | Acc] + end + end, [], [acl_req, auth_req, super_req]), + case same_host_and_port(URLs) of + true -> + [begin + {ok, Req} = application:get_env(?APP, Name), + application:set_env(?APP, Name, [{path, Path} | Req]) + end || {Name, {_, _, Path}} <- URLs], + {_, {Host, Port, _}} = lists:last(URLs), + PoolOpts = application:get_env(?APP, pool_opts, []), + application:set_env(?APP, pool_opts, [{host, Host}, {port, Port} | PoolOpts]), + ok; + false -> + {error, different_server} + end. + +same_host_and_port([_]) -> + true; +same_host_and_port([{_, {Host, Port, _}}, {_, {Host, Port, _}}]) -> + true; +same_host_and_port([{_, {Host, Port, _}}, URL = {_, {Host, Port, _}} | Rest]) -> + same_host_and_port([URL | Rest]); +same_host_and_port(_) -> + false. \ No newline at end of file diff --git a/apps/emqx_auth_http/src/emqx_auth_http_cli.erl b/apps/emqx_auth_http/src/emqx_auth_http_cli.erl index 35a20d0f0..25fac4300 100644 --- a/apps/emqx_auth_http/src/emqx_auth_http_cli.erl +++ b/apps/emqx_auth_http/src/emqx_auth_http_cli.erl @@ -16,7 +16,9 @@ -module(emqx_auth_http_cli). --export([ request/8 +-include("emqx_auth_http.hrl"). + +-export([ request/6 , feedvar/2 , feedvar/3 ]). @@ -25,36 +27,25 @@ %% HTTP Request %%-------------------------------------------------------------------- -request(get, _ContentType, Url, Params, HttpHeaders, HttpOpts, Options, RetryOpts) -> - Req = {Url ++ "?" ++ cow_qs:qs(bin_kw(Params)), HttpHeaders}, - reply(request_(get, Req, [{autoredirect, true} | HttpOpts], Options, RetryOpts)); +request(PoolName, get, Path, Headers, Params, Timeout) -> + NewPath = Path ++ "?" ++ cow_qs:qs(bin_kw(Params)), + reply(emqx_http_client:request(get, PoolName, {NewPath, Headers}, Timeout)); -request(post, 'x-www-form-urlencoded', Url, Params, HttpHeaders, HttpOpts, Options, RetryOpts) -> - Req = {Url, HttpHeaders, "application/x-www-form-urlencoded", cow_qs:qs(bin_kw(Params))}, - reply(request_(post, Req, [{autoredirect, true} | HttpOpts], Options, RetryOpts)); +request(PoolName, post, Path, Headers, Params, Timeout) -> + Body = case proplists:get_value(<<"content_type">>, Headers) of + <<"application/x-www-form-urlencoded">> -> + cow_qs:qs(bin_kw(Params)); + <<"application/json">> -> + emqx_json:encode(bin_kw(Params)) + end, + reply(emqx_http_client:request(post, PoolName, {Path, Headers, Body}, Timeout)). -request(post, json, Url, Params, HttpHeaders, HttpOpts, Options, RetryOpts) -> - Req = {Url, HttpHeaders, "application/json", emqx_json:encode(bin_kw(Params))}, - reply(request_(post, Req, [{autoredirect, true} | HttpOpts], Options, RetryOpts)). - -request_(Method, Req, HTTPOpts, Opts, RetryOpts = #{times := Times, - interval := Interval, - backoff := BackOff}) -> - case httpc:request(Method, Req, HTTPOpts, Opts) of - {error, _Reason} when Times > 0 -> - timer:sleep(trunc(Interval)), - RetryOpts1 = RetryOpts#{times := Times - 1, - interval := Interval * BackOff}, - request_(Method, Req, HTTPOpts, Opts, RetryOpts1); - Other -> Other - end. - -reply({ok, {{_, Code, _}, _Headers, Body}}) -> - {ok, Code, Body}; -reply({ok, Code, Body}) -> - {ok, Code, Body}; -reply({error, Error}) -> - {error, Error}. +reply({ok, StatusCode, _Headers}) -> + {ok, StatusCode, <<>>}; +reply({ok, StatusCode, _Headers, Body}) -> + {ok, StatusCode, Body}; +reply({error, Reason}) -> + {error, Reason}. %% TODO: move this conversion to cuttlefish config and schema bin_kw(KeywordList) when is_list(KeywordList) -> diff --git a/apps/emqx_auth_http/src/emqx_http_client.erl b/apps/emqx_auth_http/src/emqx_http_client.erl new file mode 100644 index 000000000..e29d798de --- /dev/null +++ b/apps/emqx_auth_http/src/emqx_http_client.erl @@ -0,0 +1,256 @@ +-module(emqx_http_client). + +-behaviour(gen_server). + +-include_lib("emqx/include/logger.hrl"). + +%% APIs +-export([ start_link/3 + , request/3 + , request/4 + ]). + +%% gen_server callbacks +-export([ init/1 + , handle_call/3 + , handle_cast/2 + , handle_info/2 + , terminate/2 + , code_change/3 + ]). + +-record(state, { + pool :: ecpool:poo_name(), + id :: pos_integer(), + client :: pid() | undefined, + mref :: reference() | undefined, + host :: inet:hostname() | inet:ip_address(), + port :: inet:port_number(), + gun_opts :: proplists:proplist(), + gun_state :: down | up, + requests :: map() + }). + +%%-------------------------------------------------------------------- +%% APIs +%%-------------------------------------------------------------------- + +start_link(Pool, Id, Opts) -> + gen_server:start_link(?MODULE, [Pool, Id, Opts], []). + +request(Method, Pool, Req) -> + request(Method, Pool, Req, 5000). + +request(get, Pool, {Path, Headers}, Timeout) -> + call(pick(Pool), {get, {Path, Headers}, Timeout}, Timeout + 1000); +request(Method, Pool, {Path, Headers, Body}, Timeout) -> + call(pick(Pool), {Method, {Path, Headers, Body}, Timeout}, Timeout + 1000). + +%%-------------------------------------------------------------------- +%% gen_server callbacks +%%-------------------------------------------------------------------- + +init([Pool, Id, Opts]) -> + State = #state{pool = Pool, + id = Id, + client = undefined, + mref = undefined, + host = proplists:get_value(host, Opts), + port = proplists:get_value(port, Opts), + gun_opts = gun_opts(Opts), + gun_state = down, + requests = #{}}, + true = gproc_pool:connect_worker(Pool, {Pool, Id}), + {ok, State}. + +handle_call(Req = {_, _, _}, From, State = #state{client = undefined, gun_state = down}) -> + case open(State) of + {ok, NewState} -> + handle_call(Req, From, NewState); + {error, Reason} -> + {reply, {error, Reason}, State} + end; + +handle_call(Req = {_, _, Timeout}, From, State = #state{client = Client, mref = MRef, gun_state = down}) when is_pid(Client) -> + case gun:await_up(Client, Timeout, MRef) of + {ok, _} -> + handle_call(Req, From, State#state{gun_state = up}); + {error, timeout} -> + {reply, {error, timeout}, State}; + {error, Reason} -> + true = erlang:demonitor(MRef, [flush]), + {reply, {error, Reason}, State#state{client = undefined, mref = undefined}} + end; + +handle_call({Method, Request, Timeout}, From, State = #state{client = Client, requests = Requests, gun_state = up}) when is_pid(Client) -> + StreamRef = do_request(Client, Method, Request), + ExpirationTime = erlang:system_time(millisecond) + Timeout, + {noreply, State#state{requests = maps:put(StreamRef, {From, ExpirationTime, undefined}, Requests)}}; + +handle_call(Req, _From, State) -> + ?LOG(error, "Unexpected call: ~p", [Req]), + {reply, ignored, State}. + +handle_cast(Msg, State) -> + ?LOG(error, "Unexpected cast: ~p", [Msg]), + {noreply, State}. + +handle_info({gun_response, Client, StreamRef, IsFin, StatusCode, Headers}, State = #state{client = Client, requests = Requests}) -> + Now = erlang:system_time(millisecond), + case maps:take(StreamRef, Requests) of + error -> + ?LOG(error, "Received 'gun_response' message from unknown stream ref: ~p", [StreamRef]), + {noreply, State}; + {{_, ExpirationTime, _}, NRequests} when Now > ExpirationTime -> + gun:cancel(Client, StreamRef), + flush_stream(Client, StreamRef), + {noreply, State#state{requests = NRequests}}; + {{From, ExpirationTime, undefined}, NRequests} -> + case IsFin of + fin -> + gen_server:reply(From, {ok, StatusCode, Headers}), + {noreply, State#state{requests = NRequests}}; + nofin -> + {noreply, State#state{requests = NRequests#{StreamRef => {From, ExpirationTime, {StatusCode, Headers, <<>>}}}}} + end; + _ -> + ?LOG(error, "Received 'gun_response' message does not match the state"), + {noreply, State} + end; + +handle_info({gun_data, Client, StreamRef, IsFin, Data}, State = #state{client = Client, requests = Requests}) -> + Now = erlang:system_time(millisecond), + case maps:take(StreamRef, Requests) of + error -> + ?LOG(error, "Received 'gun_data' message from unknown stream ref: ~p", [StreamRef]), + {noreply, State}; + {{_, ExpirationTime, _}, NRequests} when Now > ExpirationTime -> + gun:cancel(Client, StreamRef), + flush_stream(Client, StreamRef), + {noreply, State#state{requests = NRequests}}; + {{From, ExpirationTime, {StatusCode, Headers, Acc}}, NRequests} -> + case IsFin of + fin -> + gen_server:reply(From, {ok, StatusCode, Headers, <>}), + {noreply, State#state{requests = NRequests}}; + nofin -> + {noreply, State#state{requests = NRequests#{StreamRef => {From, ExpirationTime, {StatusCode, Headers, <>}}}}} + end; + _ -> + ?LOG(error, "Received 'gun_data' message does not match the state"), + {noreply, State} + end; + +handle_info({gun_error, Client, StreamRef, Reason}, State = #state{client = Client, requests = Requests}) -> + Now = erlang:system_time(millisecond), + case maps:take(StreamRef, Requests) of + error -> + ?LOG(error, "Received 'gun_error' message from unknown stream ref: ~p~n", [StreamRef]), + {noreply, State}; + {{_, ExpirationTime, _}, NRequests} when Now > ExpirationTime -> + {noreply, State#state{requests = NRequests}}; + {{From, _, _}, NRequests} -> + gen_server:reply(From, {error, Reason}), + {noreply, State#state{requests = NRequests}} + end; + +handle_info({gun_up, Client, _}, State = #state{client = Client}) -> + {noreply, State#state{gun_state = up}}; + +handle_info({gun_down, Client, _, Reason, KilledStreams, _}, State = #state{client = Client, requests = Requests}) -> + Now = erlang:system_time(millisecond), + NRequests = lists:foldl(fun(StreamRef, Acc) -> + case maps:take(StreamRef, Acc) of + error -> Acc; + {{_, ExpirationTime, _}, NAcc} when Now > ExpirationTime -> + NAcc; + {{From, _, _}, NAcc} -> + gen_server:reply(From, {error, Reason}), + NAcc + end + end, Requests, KilledStreams), + {noreply, State#state{gun_state = down, requests = NRequests}}; + +handle_info({'DOWN', MRef, process, Client, Reason}, State = #state{mref = MRef, client = Client, requests = Requests}) -> + true = erlang:demonitor(MRef, [flush]), + Now = erlang:system_time(millisecond), + lists:foreach(fun({_, {_, ExpirationTime, _}}) when Now > ExpirationTime -> + ok; + ({_, {From, _, _}}) -> + gen_server:reply(From, {error, Reason}) + end, maps:to_list(Requests)), + case open(State#state{requests = #{}}) of + {ok, NewState} -> + {noreply, NewState}; + {error, Reason} -> + {noreply, State#state{mref = undefined, client = undefined}} + end; + +handle_info(Info, State) -> + ?LOG(error, "Unexpected info: ~p", [Info]), + {noreply, State}. + +terminate(_Reason, #state{pool = Pool, id = Id}) -> + gropc:disconnect_worker(Pool, {Pool, Id}), + ok. + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + +%%-------------------------------------------------------------------- +%% Internal functions +%%-------------------------------------------------------------------- + +open(State = #state{host = Host, port = Port, gun_opts = GunOpts}) -> + case gun:open(Host, Port, GunOpts) of + {ok, ConnPid} when is_pid(ConnPid) -> + MRef = monitor(process, ConnPid), + {ok, State#state{mref = MRef, client = ConnPid}}; + {error, Reason} -> + {error, Reason} + end. + +gun_opts(Opts) -> + gun_opts(Opts, #{retry => 5, + retry_timeout => 1000, + connect_timeout => 5000, + protocols => [http], + http_opts => #{keepalive => infinity}}). + +gun_opts([], Acc) -> + Acc; +gun_opts([{retry, Retry} | Opts], Acc) -> + gun_opts(Opts, Acc#{retry => Retry}); +gun_opts([{retry_timeout, RetryTimeout} | Opts], Acc) -> + gun_opts(Opts, Acc#{retry_timeout => RetryTimeout}); +gun_opts([{connect_timeout, ConnectTimeout} | Opts], Acc) -> + gun_opts(Opts, Acc#{connect_timeout => ConnectTimeout}); +gun_opts([{transport, Transport} | Opts], Acc) -> + gun_opts(Opts, Acc#{transport => Transport}); +gun_opts([{transport_opts, TransportOpts} | Opts], Acc) -> + gun_opts(Opts, Acc#{transport_opts => TransportOpts}); +gun_opts([_ | Opts], Acc) -> + gun_opts(Opts, Acc). + +call(ChannPid, Msg, Timeout) -> + gen_server:call(ChannPid, Msg, Timeout). + +pick(Pool) -> + gproc_pool:pick_worker(Pool). + +do_request(Client, get, {Path, Headers}) -> + gun:get(Client, Path, Headers); +do_request(Client, post, {Path, Headers, Body}) -> + gun:post(Client, Path, Headers, Body). + +flush_stream(Client, StreamRef) -> + receive + {gun_response, Client, StreamRef, _, _, _} -> + flush_stream(Client, StreamRef); + {gun_data, Client, StreamRef, _, _} -> + flush_stream(Client, StreamRef); + {gun_error, Client, StreamRef, _} -> + flush_stream(Client, StreamRef) + after 0 -> + ok + end. \ No newline at end of file diff --git a/apps/emqx_auth_http/src/emqx_http_client_sup.erl b/apps/emqx_auth_http/src/emqx_http_client_sup.erl new file mode 100644 index 000000000..dcdd2e4c4 --- /dev/null +++ b/apps/emqx_auth_http/src/emqx_http_client_sup.erl @@ -0,0 +1,48 @@ +-module(emqx_http_client_sup). + +-behaviour(supervisor). + +-export([ start_link/2 + , init/1 + , stop_pool/1 + ]). + +start_link(Pool, Opts) -> + supervisor:start_link(?MODULE, [Pool, Opts]). + +init([Pool, Opts]) -> + PoolSize = pool_size(Opts), + ok = ensure_pool(Pool, random, [{size, PoolSize}]), + {ok, {{one_for_one, 10, 100}, [ + begin + ensure_pool_worker(Pool, {Pool, I}, I), + #{id => {Pool, I}, + start => {emqx_http_client, start_link, [Pool, I, Opts]}, + restart => transient, + shutdown => 5000, + type => worker, + modules => [emqx_http_client]} + end || I <- lists:seq(1, PoolSize)]}}. + + +ensure_pool(Pool, Type, Opts) -> + try gproc_pool:new(Pool, Type, Opts) + catch + error:exists -> ok + end. + +ensure_pool_worker(Pool, Name, Slot) -> + try gproc_pool:add_worker(Pool, Name, Slot) + catch + error:exists -> ok + end. + +pool_size(Opts) -> + Schedulers = erlang:system_info(schedulers), + proplists:get_value(pool_size, Opts, Schedulers). + +stop_pool(Name) -> + Workers = gproc_pool:defined_workers(Name), + [gproc_pool:remove_worker(Name, WokerName) || {WokerName, _, _} <- Workers], + gproc_pool:delete(Name), + ok. \ No newline at end of file diff --git a/apps/emqx_auth_http/test/emqx_auth_http_SUITE.erl b/apps/emqx_auth_http/test/emqx_auth_http_SUITE.erl index 25ff942c5..79c02c00c 100644 --- a/apps/emqx_auth_http/test/emqx_auth_http_SUITE.erl +++ b/apps/emqx_auth_http/test/emqx_auth_http_SUITE.erl @@ -64,32 +64,38 @@ set_special_configs(emqx, _Schmea, _Inet) -> emqx_ct_helpers:deps_path(emqx, LoadedPluginPath)); set_special_configs(emqx_auth_http, Schema, Inet) -> - AuthReq = maps:from_list(application:get_env(emqx_auth_http, auth_req, [])), - SuprReq = maps:from_list(application:get_env(emqx_auth_http, super_req, [])), - AclReq = maps:from_list(application:get_env(emqx_auth_http, acl_req, [])), - SvrAddr = http_server_host(Schema, Inet), + ServerAddr = http_server(Schema, Inet), - AuthReq1 = AuthReq#{method := get, url := SvrAddr ++ "/mqtt/auth"}, - SuprReq1 = SuprReq#{method := post, content_type := 'x-www-form-urlencoded', url := SvrAddr ++ "/mqtt/superuser"}, - AclReq1 = AclReq #{method := post, content_type := json, url := SvrAddr ++ "/mqtt/acl"}, + AuthReq = #{method => get, + url => ServerAddr ++ "/mqtt/auth", + content_type => <<"application/x-www-form-urlencoded">>, + params => [{"clientid", "%c"}, {"username", "%u"}, {"password", "%P"}]}, + SuperReq = #{method => post, + url => ServerAddr ++ "/mqtt/superuser", + content_type => <<"application/x-www-form-urlencoded">>, + params => [{"clientid", "%c"}, {"username", "%u"}]}, + AclReq = #{method => post, + url => ServerAddr ++ "/mqtt/acl", + content_type => <<"application/json">>, + params => [{"access", "%A"}, {"username", "%u"}, {"clientid", "%c"}, {"ipaddr", "%a"}, {"topic", "%t"}, {"mountpoint", "%m"}]}, Schema =:= https andalso set_https_client_opts(), - application:set_env(emqx_auth_http, auth_req, maps:to_list(AuthReq1)), - application:set_env(emqx_auth_http, super_req, maps:to_list(SuprReq1)), - application:set_env(emqx_auth_http, acl_req, maps:to_list(AclReq1)). + application:set_env(emqx_auth_http, auth_req, maps:to_list(AuthReq)), + application:set_env(emqx_auth_http, super_req, maps:to_list(SuperReq)), + application:set_env(emqx_auth_http, acl_req, maps:to_list(AclReq)). %% @private set_https_client_opts() -> - HttpOpts = maps:from_list(application:get_env(emqx_auth_http, http_opts, [])), - HttpOpts1 = HttpOpts#{ssl => emqx_ct_helpers:client_ssl_twoway()}, - application:set_env(emqx_auth_http, http_opts, maps:to_list(HttpOpts1)). + TransportOpts = emqx_ct_helpers:client_ssl_twoway(), + {ok, PoolOpts} = application:get_env(emqx_auth_http, pool_opts), + application:set_env(emqx_auth_http, pool_opts, [{transport_opts, TransportOpts}, {transport, ssl} | PoolOpts]). %% @private -http_server_host(http, inet) -> "http://127.0.0.1:8991"; -http_server_host(http, inet6) -> "http://[::1]:8991"; -http_server_host(https, inet) -> "https://127.0.0.1:8991"; -http_server_host(https, inet6) -> "https://[::1]:8991". +http_server(http, inet) -> "http://127.0.0.1:8991"; +http_server(http, inet6) -> "http://[::1]:8991"; +http_server(https, inet) -> "https://127.0.0.1:8991"; +http_server(https, inet6) -> "https://[::1]:8991". %%------------------------------------------------------------------------------ %% Testcases diff --git a/apps/emqx_auth_jwt/.gitignore b/apps/emqx_auth_jwt/.gitignore index d038bde4d..62e4fbb25 100644 --- a/apps/emqx_auth_jwt/.gitignore +++ b/apps/emqx_auth_jwt/.gitignore @@ -25,3 +25,4 @@ rebar3.crashdump etc/emqx_auth_jwt.conf.rendered .rebar3/ *.swp +Mnesia.nonode@nohost/ diff --git a/apps/emqx_auth_jwt/etc/emqx_auth_jwt.conf b/apps/emqx_auth_jwt/etc/emqx_auth_jwt.conf index b9a1caa04..5a599ca23 100644 --- a/apps/emqx_auth_jwt/etc/emqx_auth_jwt.conf +++ b/apps/emqx_auth_jwt/etc/emqx_auth_jwt.conf @@ -7,17 +7,28 @@ ## Value: String auth.jwt.secret = emqxsecret +## RSA or ECDSA public key file. +## +## Value: File +#auth.jwt.pubkey = etc/certs/jwt_public_key.pem + +## The JWKs server address +## +## see: http://self-issued.info/docs/draft-ietf-jose-json-web-key.html +## +#auth.jwt.jwks = https://127.0.0.1:8080/jwks + +## The JWKs refresh interval +## +## Value: Duration +#auth.jwt.jwks.refresh_interval = 5m + ## From where the JWT string can be got ## ## Value: username | password ## Default: password auth.jwt.from = password -## RSA or ECDSA public key file. -## -## Value: File -## auth.jwt.pubkey = etc/certs/jwt_public_key.pem - ## Enable to verify claims fields ## ## Value: on | off @@ -31,9 +42,4 @@ auth.jwt.verify_claims = off ## Variables: ## - %u: username ## - %c: clientid -# auth.jwt.verify_claims.username = %u - -## The Signature format -## - `der`: The erlang default format -## - `raw`: Compatible with others platform maybe -#auth.jwt.signature_format = der +#auth.jwt.verify_claims.username = %u diff --git a/apps/emqx_auth_jwt/priv/emqx_auth_jwt.schema b/apps/emqx_auth_jwt/priv/emqx_auth_jwt.schema index e8210a8cd..3d8de3678 100644 --- a/apps/emqx_auth_jwt/priv/emqx_auth_jwt.schema +++ b/apps/emqx_auth_jwt/priv/emqx_auth_jwt.schema @@ -4,6 +4,14 @@ {datatype, string} ]}. +{mapping, "auth.jwt.jwks", "emqx_auth_jwt.jwks", [ + {datatype, string} +]}. + +{mapping, "auth.jwt.jwks.refresh_interval", "emqx_auth_jwt.refresh_interval", [ + {datatype, {duration, ms}} +]}. + {mapping, "auth.jwt.from", "emqx_auth_jwt.from", [ {default, password}, {datatype, atom} @@ -13,6 +21,11 @@ {datatype, string} ]}. +{mapping, "auth.jwt.signature_format", "emqx_auth_jwt.jwerl_opts", [ + {default, "der"}, + {datatype, {enum, [raw, der]}} +]}. + {mapping, "auth.jwt.verify_claims", "emqx_auth_jwt.verify_claims", [ {default, off}, {datatype, flag} @@ -34,15 +47,3 @@ end, [], cuttlefish_variable:filter_by_prefix("auth.jwt.verify_claims", Conf)) end end}. - -{mapping, "auth.jwt.signature_format", "emqx_auth_jwt.jwerl_opts", [ - {default, "der"}, - {datatype, {enum, [raw, der]}} -]}. - -{translation, "emqx_auth_jwt.jwerl_opts", fun(Conf) -> - Filter = fun(L) -> [I || I <- L, I /= undefined] end, - maps:from_list(Filter( - [{raw, cuttlefish:conf_get("auth.jwt.signature_format", Conf) == raw}] - )) -end}. diff --git a/apps/emqx_auth_jwt/rebar.config b/apps/emqx_auth_jwt/rebar.config index f711075ba..4164d1fed 100644 --- a/apps/emqx_auth_jwt/rebar.config +++ b/apps/emqx_auth_jwt/rebar.config @@ -1,5 +1,6 @@ {deps, - [{jwerl, {git, "https://github.com/emqx/jwerl.git", {branch, "1.1.1"}}} + [ + {jose, {git, "https://github.com/potatosalad/erlang-jose", {tag, "1.10.1"}}} ]}. {edoc_opts, [{preprocess, true}]}. diff --git a/apps/emqx_auth_jwt/src/emqx_auth_jwt.app.src b/apps/emqx_auth_jwt/src/emqx_auth_jwt.app.src index 5c76d9114..e5d25e11b 100644 --- a/apps/emqx_auth_jwt/src/emqx_auth_jwt.app.src +++ b/apps/emqx_auth_jwt/src/emqx_auth_jwt.app.src @@ -3,7 +3,7 @@ {vsn, "git"}, {modules, []}, {registered, [emqx_auth_jwt_sup]}, - {applications, [kernel,stdlib,jwerl]}, + {applications, [kernel,stdlib,jose]}, {mod, {emqx_auth_jwt_app, []}}, {env, []}, {licenses, ["Apache-2.0"]}, diff --git a/apps/emqx_auth_jwt/src/emqx_auth_jwt.appup.src b/apps/emqx_auth_jwt/src/emqx_auth_jwt.appup.src new file mode 100644 index 000000000..0c7b8ebf3 --- /dev/null +++ b/apps/emqx_auth_jwt/src/emqx_auth_jwt.appup.src @@ -0,0 +1,10 @@ +%% -*-: erlang -*- + +{VSN, + [ + {<<".*">>, []} + ], + [ + {<<".*">>, []} + ] +}. diff --git a/apps/emqx_auth_jwt/src/emqx_auth_jwt.erl b/apps/emqx_auth_jwt/src/emqx_auth_jwt.erl index a00bc2577..6be726dc9 100644 --- a/apps/emqx_auth_jwt/src/emqx_auth_jwt.erl +++ b/apps/emqx_auth_jwt/src/emqx_auth_jwt.erl @@ -46,77 +46,31 @@ register_metrics() -> %% Authentication callbacks %%-------------------------------------------------------------------- -check(ClientInfo, AuthResult, Env = #{from := From, checklists := Checklists}) -> +check(ClientInfo, AuthResult, #{pid := Pid, + from := From, + checklists := Checklists}) -> case maps:find(From, ClientInfo) of error -> - ok = emqx_metrics:inc(?AUTH_METRICS(ignore)), - {ok, AuthResult#{auth_result => token_undefined, anonymous => false}}; + ok = emqx_metrics:inc(?AUTH_METRICS(ignore)); + {ok, undefined} -> + ok = emqx_metrics:inc(?AUTH_METRICS(ignore)); {ok, Token} -> - try jwerl:header(Token) of - Headers -> - case verify_token(Headers, Token, Env) of - {ok, Claims} -> - {stop, maps:merge(AuthResult, verify_claims(Checklists, Claims, ClientInfo))}; - {error, Reason} -> - ok = emqx_metrics:inc(?AUTH_METRICS(failure)), - {stop, AuthResult#{auth_result => Reason, anonymous => false}} - end - catch - _Error:Reason -> - ?LOG(error, "Check token error: ~p", [Reason]), - emqx_metrics:inc(?AUTH_METRICS(ignore)) + case emqx_auth_jwt_svr:verify(Pid, Token) of + {error, not_found} -> + ok = emqx_metrics:inc(?AUTH_METRICS(ignore)); + {error, not_token} -> + ok = emqx_metrics:inc(?AUTH_METRICS(ignore)); + {error, Reason} -> + ok = emqx_metrics:inc(?AUTH_METRICS(failure)), + {stop, AuthResult#{auth_result => Reason, anonymous => false}}; + {ok, Claims} -> + {stop, maps:merge(AuthResult, verify_claims(Checklists, Claims, ClientInfo))} end end. description() -> "Authentication with JWT". -%%-------------------------------------------------------------------- -%% Verify Token -%%-------------------------------------------------------------------- - -verify_token(#{alg := <<"HS", _/binary>>}, _Token, #{secret := undefined}) -> - {error, hmac_secret_undefined}; -verify_token(#{alg := Alg = <<"HS", _/binary>>}, Token, #{secret := Secret, opts := Opts}) -> - verify_token2(Alg, Token, Secret, Opts); - -verify_token(#{alg := <<"RS", _/binary>>}, _Token, #{pubkey := undefined}) -> - {error, rsa_pubkey_undefined}; -verify_token(#{alg := Alg = <<"RS", _/binary>>}, Token, #{pubkey := PubKey, opts := Opts}) -> - verify_token2(Alg, Token, PubKey, Opts); - -verify_token(#{alg := <<"ES", _/binary>>}, _Token, #{pubkey := undefined}) -> - {error, ecdsa_pubkey_undefined}; -verify_token(#{alg := Alg = <<"ES", _/binary>>}, Token, #{pubkey := PubKey, opts := Opts}) -> - verify_token2(Alg, Token, PubKey, Opts); - -verify_token(Header, _Token, _Env) -> - ?LOG(error, "Unsupported token algorithm: ~p", [Header]), - {error, token_unsupported}. - -verify_token2(Alg, Token, SecretOrKey, Opts) -> - try jwerl:verify(Token, decode_algo(Alg), SecretOrKey, #{}, Opts) of - {ok, Claims} -> - {ok, Claims}; - {error, Reason} -> - {error, Reason} - catch - _Error:Reason -> - {error, Reason} - end. - -decode_algo(<<"HS256">>) -> hs256; -decode_algo(<<"HS384">>) -> hs384; -decode_algo(<<"HS512">>) -> hs512; -decode_algo(<<"RS256">>) -> rs256; -decode_algo(<<"RS384">>) -> rs384; -decode_algo(<<"RS512">>) -> rs512; -decode_algo(<<"ES256">>) -> es256; -decode_algo(<<"ES384">>) -> es384; -decode_algo(<<"ES512">>) -> es512; -decode_algo(<<"none">>) -> none; -decode_algo(Alg) -> throw({error, {unsupported_algorithm, Alg}}). - -%%-------------------------------------------------------------------- +%%------------------------------------------------------------------------------ %% Verify Claims %%-------------------------------------------------------------------- @@ -143,4 +97,3 @@ feedvar(Checklists, #{username := Username, clientid := ClientId}) -> ({K, <<"%c">>}) -> {K, ClientId}; ({K, Expected}) -> {K, Expected} end, Checklists). - diff --git a/apps/emqx_auth_jwt/src/emqx_auth_jwt_app.erl b/apps/emqx_auth_jwt/src/emqx_auth_jwt_app.erl index 511f6e826..736fb28b9 100644 --- a/apps/emqx_auth_jwt/src/emqx_auth_jwt_app.erl +++ b/apps/emqx_auth_jwt/src/emqx_auth_jwt_app.erl @@ -28,42 +28,55 @@ -define(APP, emqx_auth_jwt). --define(JWT_ACTION, {emqx_auth_jwt, check, [auth_env()]}). - start(_Type, _Args) -> - ok = emqx_auth_jwt:register_metrics(), - emqx:hook('client.authenticate', ?JWT_ACTION), - supervisor:start_link({local, ?MODULE}, ?MODULE, []). + {ok, Sup} = supervisor:start_link({local, ?MODULE}, ?MODULE, []), -stop(_State) -> - emqx:unhook('client.authenticate', ?JWT_ACTION). + {ok, Pid} = start_auth_server(jwks_svr_options()), + ok = emqx_auth_jwt:register_metrics(), + + AuthEnv0 = auth_env(), + AuthEnv1 = AuthEnv0#{pid => Pid}, + + emqx:hook('client.authenticate', {emqx_auth_jwt, check, [AuthEnv1]}), + {ok, Sup, AuthEnv1}. + +stop(AuthEnv) -> + emqx:unhook('client.authenticate', {emqx_auth_jwt, check, [AuthEnv]}). %%-------------------------------------------------------------------- %% Dummy supervisor %%-------------------------------------------------------------------- init([]) -> - {ok, { {one_for_all, 1, 10}, []} }. + {ok, {{one_for_all, 1, 10}, []}}. + +start_auth_server(Options) -> + Spec = #{id => jwt_svr, + start => {emqx_auth_jwt_svr, start_link, [Options]}, + restart => permanent, + shutdown => brutal_kill, + type => worker, + modules => [emqx_auth_jwt_svr]}, + supervisor:start_child(?MODULE, Spec). %%-------------------------------------------------------------------- %% Internal functions %%-------------------------------------------------------------------- auth_env() -> - #{secret => env(secret, undefined), - from => env(from, password), - pubkey => read_pubkey(), - checklists => env(verify_claims, []), - opts => env(jwerl_opts, #{}) + Checklists = [{atom_to_binary(K, utf8), V} + || {K, V} <- env(verify_claims, [])], + #{ from => env(from, password) + , checklists => Checklists }. -read_pubkey() -> - case env(pubkey, undefined) of - undefined -> undefined; - Path -> - {ok, PubKey} = file:read_file(Path), PubKey - end. +jwks_svr_options() -> + [{K, V} || {K, V} + <- [{secret, env(secret, undefined)}, + {pubkey, env(pubkey, undefined)}, + {jwks_addr, env(jwks, undefined)}, + {interval, env(refresh_interval, undefined)}], + V /= undefined]. env(Key, Default) -> application:get_env(?APP, Key, Default). - diff --git a/apps/emqx_auth_jwt/src/emqx_auth_jwt_svr.erl b/apps/emqx_auth_jwt/src/emqx_auth_jwt_svr.erl new file mode 100644 index 000000000..b347d0e0b --- /dev/null +++ b/apps/emqx_auth_jwt/src/emqx_auth_jwt_svr.erl @@ -0,0 +1,222 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_auth_jwt_svr). + +-behaviour(gen_server). + +-include_lib("emqx/include/logger.hrl"). +-include_lib("jose/include/jose_jwk.hrl"). + +-logger_header("[JWT-SVR]"). + +%% APIs +-export([start_link/1]). + +-export([verify/2]). + +%% gen_server callbacks +-export([ init/1 + , handle_call/3 + , handle_cast/2 + , handle_info/2 + , terminate/2 + , code_change/3 + ]). + +-type options() :: [option()]. +-type option() :: {secret, list()} + | {pubkey, list()} + | {jwks_addr, list()} + | {interval, pos_integer()}. + +-define(INTERVAL, 300000). + +-record(state, {static, remote, addr, tref, intv}). + +%%-------------------------------------------------------------------- +%% APIs +%%-------------------------------------------------------------------- + +-spec start_link(options()) -> gen_server:start_ret(). +start_link(Options) -> + gen_server:start_link(?MODULE, [Options], []). + +-spec verify(pid(), binary()) + -> {error, term()} + | {ok, Payload :: map()}. +verify(S, JwsCompacted) when is_binary(JwsCompacted) -> + case catch jose_jws:peek(JwsCompacted) of + {'EXIT', _} -> {error, not_token}; + _ -> gen_server:call(S, {verify, JwsCompacted}) + end. + +%%-------------------------------------------------------------------- +%% gen_server callbacks +%%-------------------------------------------------------------------- + +init([Options]) -> + ok = jose:json_module(jiffy), + {Static, Remote} = do_init_jwks(Options), + Intv = proplists:get_value(interval, Options, ?INTERVAL), + {ok, reset_timer( + #state{ + static = Static, + remote = Remote, + addr = proplists:get_value(jwks_addr, Options), + intv = Intv})}. + +%% @private +do_init_jwks(Options) -> + K2J = fun(K, F) -> + case proplists:get_value(K, Options) of + undefined -> undefined; + V -> + try F(V) of + {error, Reason} -> + ?LOG(warning, "Build ~p JWK ~p failed: {error, ~p}~n", + [K, V, Reason]), + undefined; + J -> J + catch T:R:_ -> + ?LOG(warning, "Build ~p JWK ~p failed: {~p, ~p}~n", + [K, V, T, R]), + undefined + end + end + end, + OctJwk = K2J(secret, fun(V) -> + jose_jwk:from_oct(list_to_binary(V)) + end), + PemJwk = K2J(pubkey, fun jose_jwk:from_pem_file/1), + Remote = K2J(jwks_addr, fun request_jwks/1), + {[J ||J <- [OctJwk, PemJwk], J /= undefined], Remote}. + +handle_call({verify, JwsCompacted}, _From, State) -> + handle_verify(JwsCompacted, State); + +handle_call(_Req, _From, State) -> + {reply, ok, State}. + +handle_cast(_Msg, State) -> + {noreply, State}. + +handle_info({timeout, _TRef, refresh}, State = #state{addr = Addr}) -> + NState = try + State#state{remote = request_jwks(Addr)} + catch _:_ -> + State + end, + {noreply, reset_timer(NState)}; + +handle_info(_Info, State) -> + {noreply, State}. + +terminate(_Reason, State) -> + _ = cancel_timer(State), + ok. + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + +%%-------------------------------------------------------------------- +%% Internal funcs +%%-------------------------------------------------------------------- + +handle_verify(JwsCompacted, + State = #state{static = Static, remote = Remote}) -> + try + Jwks = case emqx_json:decode(jose_jws:peek_protected(JwsCompacted), [return_maps]) of + #{<<"kid">> := Kid} -> + [J || J <- Remote, maps:get(<<"kid">>, J#jose_jwk.fields, undefined) =:= Kid]; + _ -> Static + end, + case Jwks of + [] -> {reply, {error, not_found}, State}; + _ -> + {reply, do_verify(JwsCompacted, Jwks), State} + end + catch + _:_ -> + {reply, {error, invalid_signature}, State} + end. + +request_jwks(Addr) -> + case httpc:request(get, {Addr, []}, [], [{body_format, binary}]) of + {error, Reason} -> + error(Reason); + {ok, {_Code, _Headers, Body}} -> + try + JwkSet = jose_jwk:from(emqx_json:decode(Body, [return_maps])), + {_, Jwks} = JwkSet#jose_jwk.keys, Jwks + catch _:_ -> + ?LOG(error, "Invalid jwks server response: ~p~n", [Body]), + error(badarg) + end + end. + +reset_timer(State = #state{addr = undefined}) -> + State; +reset_timer(State = #state{intv = Intv}) -> + State#state{tref = erlang:start_timer(Intv, self(), refresh)}. + +cancel_timer(State = #state{tref = undefined}) -> + State; +cancel_timer(State = #state{tref = TRef}) -> + erlang:cancel_timer(TRef), + State#state{tref = undefined}. + +do_verify(_JwsCompated, []) -> + {error, invalid_signature}; +do_verify(JwsCompacted, [Jwk|More]) -> + case jose_jws:verify(Jwk, JwsCompacted) of + {true, Payload, _Jws} -> + Claims = emqx_json:decode(Payload, [return_maps]), + case check_claims(Claims) of + false -> + {error, invalid_signature}; + NClaims -> + {ok, NClaims} + end; + {false, _, _} -> + do_verify(JwsCompacted, More) + end. + +check_claims(Claims) -> + Now = os:system_time(seconds), + Checker = [{<<"exp">>, fun(ExpireTime) -> + Now < ExpireTime + end}, + {<<"iat">>, fun(IssueAt) -> + IssueAt =< Now + end}, + {<<"nbf">>, fun(NotBefore) -> + NotBefore =< Now + end} + ], + do_check_claim(Checker, Claims). + +do_check_claim([], Claims) -> + Claims; +do_check_claim([{K, F}|More], Claims) -> + case maps:take(K, Claims) of + error -> do_check_claim(More, Claims); + {V, NClaims} -> + case F(V) of + true -> do_check_claim(More, NClaims); + _ -> false + end + end. diff --git a/apps/emqx_auth_jwt/test/emqx_auth_jwt_SUITE.erl b/apps/emqx_auth_jwt/test/emqx_auth_jwt_SUITE.erl index 190c3db14..12f307b2a 100644 --- a/apps/emqx_auth_jwt/test/emqx_auth_jwt_SUITE.erl +++ b/apps/emqx_auth_jwt/test/emqx_auth_jwt_SUITE.erl @@ -16,8 +16,8 @@ -module(emqx_auth_jwt_SUITE). --compile(nowarn_export_all). -compile(export_all). +-compile(nowarn_export_all). -include_lib("emqx/include/emqx.hrl"). -include_lib("eunit/include/eunit.hrl"). @@ -61,28 +61,34 @@ set_special_configs(emqx_auth_jwt) -> set_special_configs(_) -> ok. +sign(Payload, Alg, Key) -> + Jwk = jose_jwk:from_oct(Key), + Jwt = emqx_json:encode(Payload), + {_, Token} = jose_jws:compact(jose_jwt:sign(Jwk, #{<<"alg">> => Alg}, Jwt)), + Token. + %%------------------------------------------------------------------------------ %% Testcases %%------------------------------------------------------------------------------ t_check_auth(_) -> Plain = #{clientid => <<"client1">>, username => <<"plain">>, zone => external}, - Jwt = jwerl:sign([{clientid, <<"client1">>}, - {username, <<"plain">>}, - {exp, os:system_time(seconds) + 3}], hs256, <<"emqxsecret">>), + Jwt = sign([{clientid, <<"client1">>}, + {username, <<"plain">>}, + {exp, os:system_time(seconds) + 3}], <<"HS256">>, <<"emqxsecret">>), ct:pal("Jwt: ~p~n", [Jwt]), Result0 = emqx_access_control:authenticate(Plain#{password => Jwt}), ct:pal("Auth result: ~p~n", [Result0]), - ?assertMatch({ok, #{auth_result := success, jwt_claims := #{clientid := <<"client1">>}}}, Result0), + ?assertMatch({ok, #{auth_result := success, jwt_claims := #{<<"clientid">> := <<"client1">>}}}, Result0), ct:sleep(3100), Result1 = emqx_access_control:authenticate(Plain#{password => Jwt}), ct:pal("Auth result after 1000ms: ~p~n", [Result1]), ?assertMatch({error, _}, Result1), - Jwt_Error = jwerl:sign([{clientid, <<"client1">>}, - {username, <<"plain">>}], hs256, <<"secret">>), + Jwt_Error = sign([{client_id, <<"client1">>}, + {username, <<"plain">>}], <<"HS256">>, <<"secret">>), ct:pal("invalid jwt: ~p~n", [Jwt_Error]), Result2 = emqx_access_control:authenticate(Plain#{password => Jwt_Error}), ct:pal("Auth result for the invalid jwt: ~p~n", [Result2]), @@ -92,15 +98,15 @@ t_check_auth(_) -> t_check_claims(_) -> application:set_env(emqx_auth_jwt, verify_claims, [{sub, <<"value">>}]), Plain = #{clientid => <<"client1">>, username => <<"plain">>, zone => external}, - Jwt = jwerl:sign([{clientid, <<"client1">>}, - {username, <<"plain">>}, - {sub, value}, - {exp, os:system_time(seconds) + 3}], hs256, <<"emqxsecret">>), + Jwt = sign([{client_id, <<"client1">>}, + {username, <<"plain">>}, + {sub, value}, + {exp, os:system_time(seconds) + 3}], <<"HS256">>, <<"emqxsecret">>), Result0 = emqx_access_control:authenticate(Plain#{password => Jwt}), ct:pal("Auth result: ~p~n", [Result0]), ?assertMatch({ok, #{auth_result := success, jwt_claims := _}}, Result0), - Jwt_Error = jwerl:sign([{clientid, <<"client1">>}, - {username, <<"plain">>}], hs256, <<"secret">>), + Jwt_Error = sign([{clientid, <<"client1">>}, + {username, <<"plain">>}], <<"HS256">>, <<"secret">>), Result2 = emqx_access_control:authenticate(Plain#{password => Jwt_Error}), ct:pal("Auth result for the invalid jwt: ~p~n", [Result2]), ?assertEqual({error, invalid_signature}, Result2). @@ -108,14 +114,14 @@ t_check_claims(_) -> t_check_claims_clientid(_) -> application:set_env(emqx_auth_jwt, verify_claims, [{clientid, <<"%c">>}]), Plain = #{clientid => <<"client23">>, username => <<"plain">>, zone => external}, - Jwt = jwerl:sign([{clientid, <<"client23">>}, - {username, <<"plain">>}, - {exp, os:system_time(seconds) + 3}], hs256, <<"emqxsecret">>), + Jwt = sign([{client_id, <<"client23">>}, + {username, <<"plain">>}, + {exp, os:system_time(seconds) + 3}], <<"HS256">>, <<"emqxsecret">>), Result0 = emqx_access_control:authenticate(Plain#{password => Jwt}), ct:pal("Auth result: ~p~n", [Result0]), ?assertMatch({ok, #{auth_result := success, jwt_claims := _}}, Result0), - Jwt_Error = jwerl:sign([{clientid, <<"client1">>}, - {username, <<"plain">>}], hs256, <<"secret">>), + Jwt_Error = sign([{clientid, <<"client1">>}, + {username, <<"plain">>}], <<"HS256">>, <<"secret">>), Result2 = emqx_access_control:authenticate(Plain#{password => Jwt_Error}), ct:pal("Auth result for the invalid jwt: ~p~n", [Result2]), ?assertEqual({error, invalid_signature}, Result2). @@ -123,15 +129,14 @@ t_check_claims_clientid(_) -> t_check_claims_username(_) -> application:set_env(emqx_auth_jwt, verify_claims, [{username, <<"%u">>}]), Plain = #{clientid => <<"client23">>, username => <<"plain">>, zone => external}, - Jwt = jwerl:sign([{clientid, <<"client23">>}, - {username, <<"plain">>}, - {exp, os:system_time(seconds) + 3}], hs256, <<"emqxsecret">>), + Jwt = sign([{client_id, <<"client23">>}, + {username, <<"plain">>}, + {exp, os:system_time(seconds) + 3}], <<"HS256">>, <<"emqxsecret">>), Result0 = emqx_access_control:authenticate(Plain#{password => Jwt}), ct:pal("Auth result: ~p~n", [Result0]), ?assertMatch({ok, #{auth_result := success, jwt_claims := _}}, Result0), - Jwt_Error = jwerl:sign([{clientid, <<"client1">>}, - {username, <<"plain">>}], hs256, <<"secret">>), + Jwt_Error = sign([{clientid, <<"client1">>}, + {username, <<"plain">>}], <<"HS256">>, <<"secret">>), Result3 = emqx_access_control:authenticate(Plain#{password => Jwt_Error}), ct:pal("Auth result for the invalid jwt: ~p~n", [Result3]), ?assertEqual({error, invalid_signature}, Result3). - diff --git a/apps/emqx_auth_ldap/rebar.config b/apps/emqx_auth_ldap/rebar.config index 983d6b88c..c155efaed 100644 --- a/apps/emqx_auth_ldap/rebar.config +++ b/apps/emqx_auth_ldap/rebar.config @@ -1,6 +1,6 @@ {deps, [{eldap2, {git, "https://github.com/emqx/eldap2", {tag, "v0.2.2"}}}, - {ecpool, {git, "https://github.com/emqx/ecpool", {tag, "v0.4.2"}}}, + {ecpool, {git, "https://github.com/emqx/ecpool", {tag, "0.5.0"}}}, {emqx_passwd, {git, "https://github.com/emqx/emqx-passwd", {tag, "v1.1.1"}}} ]}. diff --git a/apps/emqx_auth_mongo/rebar.config b/apps/emqx_auth_mongo/rebar.config index cebbf68f9..b946b5a95 100644 --- a/apps/emqx_auth_mongo/rebar.config +++ b/apps/emqx_auth_mongo/rebar.config @@ -1,6 +1,6 @@ {deps, [{mongodb, {git,"https://github.com/emqx/mongodb-erlang", {tag, "v3.0.7"}}}, - {ecpool, {git,"https://github.com/emqx/ecpool", {tag, "v0.4.2"}}}, + {ecpool, {git,"https://github.com/emqx/ecpool", {tag, "0.5.0"}}}, {emqx_passwd, {git, "https://github.com/emqx/emqx-passwd", {tag, "v1.1.1"}}} ]}. @@ -28,7 +28,7 @@ [{test, [{deps, [{emqx_ct_helper, {git, "https://github.com/emqx/emqx-ct-helper", {tag, "1.2.2"}}}, - {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.0"}}} + {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.3"}}} ]}, {erl_opts, [debug_info]} ]} diff --git a/apps/emqx_auth_mysql/rebar.config b/apps/emqx_auth_mysql/rebar.config index c77aef44c..2a0aa4138 100644 --- a/apps/emqx_auth_mysql/rebar.config +++ b/apps/emqx_auth_mysql/rebar.config @@ -28,7 +28,7 @@ [{test, [{deps, [{emqx_ct_helpers, {git, "https://github.com/emqx/emqx-ct-helpers", {tag, "1.2.2"}}}, - {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.0"}}} + {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.3"}}} ]}, {erl_opts, [debug_info]} ]} diff --git a/apps/emqx_auth_mysql/src/emqx_auth_mysql.appup.src b/apps/emqx_auth_mysql/src/emqx_auth_mysql.appup.src index e4c16f92b..a4c8410af 100644 --- a/apps/emqx_auth_mysql/src/emqx_auth_mysql.appup.src +++ b/apps/emqx_auth_mysql/src/emqx_auth_mysql.appup.src @@ -1,25 +1,9 @@ %% -*-: erlang -*- -{"4.2.3", +{VSN, [ - {"4.2.2", [ - {load_module, emqx_auth_mysql_cli, brutal_purge, soft_purge, []} - ]}, - {"4.2.1", [ - {load_module, emqx_auth_mysql_cli, brutal_purge, soft_purge, []} - ]}, - {"4.2.0", [ - {load_module, emqx_auth_mysql_cli, brutal_purge, soft_purge, []} - ]} + {<<".*">>, []} ], [ - {"4.2.2", [ - {load_module, emqx_auth_mysql_cli, brutal_purge, soft_purge, []} - ]}, - {"4.2.1", [ - {load_module, emqx_auth_mysql_cli, brutal_purge, soft_purge, []} - ]}, - {"4.2.0", [ - {load_module, emqx_auth_mysql_cli, brutal_purge, soft_purge, []} - ]} + {<<".*">>, []} ] }. diff --git a/apps/emqx_auth_pgsql/rebar.config b/apps/emqx_auth_pgsql/rebar.config index 469412195..98b95fce7 100644 --- a/apps/emqx_auth_pgsql/rebar.config +++ b/apps/emqx_auth_pgsql/rebar.config @@ -1,6 +1,6 @@ {deps, [{epgsql, {git, "https://github.com/epgsql/epgsql", {tag, "4.4.0"}}}, - {ecpool, {git, "https://github.com/emqx/ecpool", {tag, "v0.4.2"}}}, + {ecpool, {git,"https://github.com/emqx/ecpool", {tag, "0.5.0"}}}, {emqx_passwd, {git, "https://github.com/emqx/emqx-passwd", {tag, "v1.1.1"}}} ]}. @@ -26,7 +26,7 @@ [{test, [{deps, [{emqx_ct_helper, {git, "https://github.com/emqx/emqx-ct-helper", {branch, "1.2.2"}}}, - {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.0"}}} + {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.3"}}} ]}, {erl_opts, [debug_info]} ]} diff --git a/apps/emqx_auth_pgsql/src/emqx_auth_pgsql.appup.src b/apps/emqx_auth_pgsql/src/emqx_auth_pgsql.appup.src index 657b119a1..a4c8410af 100644 --- a/apps/emqx_auth_pgsql/src/emqx_auth_pgsql.appup.src +++ b/apps/emqx_auth_pgsql/src/emqx_auth_pgsql.appup.src @@ -1,25 +1,9 @@ %% -*-: erlang -*- -{"4.2.3", +{VSN, [ - {"4.2.2", [ - {load_module, emqx_auth_pgsql_cli, brutal_purge, soft_purge, []} - ]}, - {"4.2.1", [ - {load_module, emqx_auth_pgsql_cli, brutal_purge, soft_purge, []} - ]}, - {"4.2.0", [ - {load_module, emqx_auth_pgsql_cli, brutal_purge, soft_purge, []} - ]} + {<<".*">>, []} ], [ - {"4.2.2", [ - {load_module, emqx_auth_pgsql_cli, brutal_purge, soft_purge, []} - ]}, - {"4.2.1", [ - {load_module, emqx_auth_pgsql_cli, brutal_purge, soft_purge, []} - ]}, - {"4.2.0", [ - {load_module, emqx_auth_pgsql_cli, brutal_purge, soft_purge, []} - ]} + {<<".*">>, []} ] }. diff --git a/apps/emqx_auth_redis/.gitignore b/apps/emqx_auth_redis/.gitignore index d7472fa8f..0cfec36f4 100644 --- a/apps/emqx_auth_redis/.gitignore +++ b/apps/emqx_auth_redis/.gitignore @@ -22,3 +22,5 @@ erlang.mk .rebar3/ *.swp rebar.lock +/.idea/ +.DS_Store diff --git a/apps/emqx_auth_redis/rebar.config b/apps/emqx_auth_redis/rebar.config index 5fac9befb..367b74acd 100644 --- a/apps/emqx_auth_redis/rebar.config +++ b/apps/emqx_auth_redis/rebar.config @@ -1,6 +1,6 @@ {deps, - [{eredis_cluster, {git, "https://github.com/emqx/eredis_cluster", {tag, "0.6.2"}}}, - {ecpool, {git, "https://github.com/emqx/ecpool", {tag, "v0.4.2"}}}, + [{eredis_cluster, {git, "https://github.com/emqx/eredis_cluster", {tag, "0.6.3"}}}, + {ecpool, {git, "https://github.com/emqx/ecpool", {tag, "0.5.0"}}}, {emqx_passwd, {git, "https://github.com/emqx/emqx-passwd", {tag, "v1.1.1"}}} ]}. @@ -26,7 +26,7 @@ [{test, [{deps, [{emqx_ct_helper, {git, "https://github.com/emqx/emqx-ct-helper", {tag, "1.2.2"}}}, - {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.0"}}} + {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.3"}}} ]}, {erl_opts, [debug_info]} ]} diff --git a/apps/emqx_auth_redis/src/emqx_auth_redis.appup.src b/apps/emqx_auth_redis/src/emqx_auth_redis.appup.src index 671b9274f..d05d8148f 100644 --- a/apps/emqx_auth_redis/src/emqx_auth_redis.appup.src +++ b/apps/emqx_auth_redis/src/emqx_auth_redis.appup.src @@ -1,36 +1,10 @@ -{"4.2.3", +%% -*-: erlang -*- + +{VSN, [ - {"4.2.2", [ - {load_module, emqx_auth_redis_cli, brutal_purge, soft_purge, []}, - {load_module, emqx_auth_redis_sup, brutal_purge, soft_purge, []} - ] - }, - {"4.2.1", [ - {load_module, emqx_auth_redis_cli, brutal_purge, soft_purge, []}, - {load_module, emqx_auth_redis_sup, brutal_purge, soft_purge, []} - ] - }, - {"4.2.0", [ - {load_module, emqx_auth_redis_cli, brutal_purge, soft_purge, []}, - {load_module, emqx_auth_redis_sup, brutal_purge, soft_purge, []} - ] - } + {<<".*">>, []} ], [ - {"4.2.2", [ - {load_module, emqx_auth_redis_cli, brutal_purge, soft_purge, []}, - {load_module, emqx_auth_redis_sup, brutal_purge, soft_purge, []} - ] - }, - {"4.2.1", [ - {load_module, emqx_auth_redis_cli, brutal_purge, soft_purge, []}, - {load_module, emqx_auth_redis_sup, brutal_purge, soft_purge, []} - ] - }, - {"4.2.0", [ - {load_module, emqx_auth_redis_cli, brutal_purge, soft_purge, []}, - {load_module, emqx_auth_redis_sup, brutal_purge, soft_purge, []} - ] - } + {<<".*">>, []} ] -}. \ No newline at end of file +}. diff --git a/apps/emqx_auth_redis/src/emqx_auth_redis_sup.erl b/apps/emqx_auth_redis/src/emqx_auth_redis_sup.erl index 6066a306a..83112976d 100644 --- a/apps/emqx_auth_redis/src/emqx_auth_redis_sup.erl +++ b/apps/emqx_auth_redis/src/emqx_auth_redis_sup.erl @@ -35,7 +35,7 @@ pool_spec(Server) -> Options = application:get_env(?APP, options, []), case proplists:get_value(type, Server) of cluster -> - eredis_cluster:start_pool(?APP, Server ++ Options), + {ok, _} = eredis_cluster:start_pool(?APP, Server ++ Options), []; _ -> [ecpool:pool_spec(?APP, ?APP, emqx_auth_redis_cli, Server ++ Options)] diff --git a/apps/emqx_bridge_mqtt/rebar.config b/apps/emqx_bridge_mqtt/rebar.config index 1c9f5cbdd..8440b2e33 100644 --- a/apps/emqx_bridge_mqtt/rebar.config +++ b/apps/emqx_bridge_mqtt/rebar.config @@ -1,7 +1,7 @@ {deps, [{replayq, {git, "https://github.com/emqx/replayq", {tag, "v0.2.0"}}}, - {ecpool, {git, "https://github.com/emqx/ecpool", {tag, "v0.4.2"}}}, - {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.0"}}} + {ecpool, {git, "https://github.com/emqx/ecpool", {tag, "0.5.0"}}}, + {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.3"}}} ]}. {edoc_opts, [{preprocess, true}]}. diff --git a/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt.appup.src b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt.appup.src index 8264abc25..f6d128b08 100644 --- a/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt.appup.src +++ b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt.appup.src @@ -1,32 +1,10 @@ %% -*-: erlang -*- -{"4.2.3", +{VSN, [ - {"4.2.2", [ - {load_module, emqx_bridge_mqtt_actions, brutal_purge, soft_purge, []}, - {apply, {emqx_rule_engine, load_providers, []}} - ]}, - {"4.2.1", [ - {load_module, emqx_bridge_mqtt_actions, brutal_purge, soft_purge, []}, - {apply, {emqx_rule_engine, load_providers, []}} - ]}, - {"4.2.0", [ - {load_module, emqx_bridge_mqtt_actions, brutal_purge, soft_purge, []}, - {apply, {emqx_rule_engine, load_providers, []}} - ]} + {<<".*">>, []} ], [ - {"4.2.2", [ - {load_module, emqx_bridge_mqtt_actions, brutal_purge, soft_purge, []}, - {apply, {emqx_rule_engine, load_providers, []}} - ]}, - {"4.2.1", [ - {load_module, emqx_bridge_mqtt_actions, brutal_purge, soft_purge, []}, - {apply, {emqx_rule_engine, load_providers, []}} - ]}, - {"4.2.0", [ - {load_module, emqx_bridge_mqtt_actions, brutal_purge, soft_purge, []}, - {apply, {emqx_rule_engine, load_providers, []}} - ]} + {<<"*.">>, []} ] }. diff --git a/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt.erl b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt.erl index c189a2ea2..19c0c5711 100644 --- a/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt.erl +++ b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt.erl @@ -31,6 +31,12 @@ , ensure_unsubscribed/2 ]). +%% callbacks for emqtt +-export([ handle_puback/2 + , handle_publish/2 + , handle_disconnected/2 + ]). + -include_lib("emqx/include/logger.hrl"). -include_lib("emqx/include/emqx_mqtt.hrl"). @@ -134,23 +140,23 @@ send(#{client_pid := ClientPid} = Conn, [Msg | Rest], _PktId) -> end. -handle_puback(Parent, #{packet_id := PktId, reason_code := RC}) +handle_puback(#{packet_id := PktId, reason_code := RC}, Parent) when RC =:= ?RC_SUCCESS; RC =:= ?RC_NO_MATCHING_SUBSCRIBERS -> Parent ! {batch_ack, PktId}, ok; -handle_puback(_Parent, #{packet_id := PktId, reason_code := RC}) -> +handle_puback(#{packet_id := PktId, reason_code := RC}, _Parent) -> ?LOG(warning, "Publish ~p to remote node falied, reason_code: ~p", [PktId, RC]). handle_publish(Msg, Mountpoint) -> emqx_broker:publish(emqx_bridge_msg:to_broker_msg(Msg, Mountpoint)). -handle_disconnected(Parent, Reason) -> +handle_disconnected(Reason, Parent) -> Parent ! {disconnected, self(), Reason}. make_hdlr(Parent, Mountpoint) -> - #{puback => fun(Ack) -> handle_puback(Parent, Ack) end, - publish => fun(Msg) -> handle_publish(Msg, Mountpoint) end, - disconnected => fun(Reason) -> handle_disconnected(Parent, Reason) end + #{puback => {fun ?MODULE:handle_puback/2, [Parent]}, + publish => {fun ?MODULE:handle_publish/2, [Mountpoint]}, + disconnected => {fun ?MODULE:handle_disconnected/2, [Parent]} }. subscribe_remote_topics(ClientPid, Subscriptions) -> diff --git a/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_actions.erl b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_actions.erl index e46c6d05b..7708521e8 100644 --- a/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_actions.erl +++ b/apps/emqx_bridge_mqtt/src/emqx_bridge_mqtt_actions.erl @@ -20,6 +20,7 @@ -include_lib("emqx/include/emqx.hrl"). -include_lib("emqx/include/logger.hrl"). +-include_lib("emqx_rule_engine/include/rule_actions.hrl"). -import(emqx_rule_utils, [str/1]). @@ -33,7 +34,9 @@ -export([subscriptions/1]). --export([on_action_create_data_to_mqtt_broker/2]). +-export([ on_action_create_data_to_mqtt_broker/2 + , on_action_data_to_mqtt_broker/2 + ]). -define(RESOURCE_TYPE_MQTT, 'bridge_mqtt'). -define(RESOURCE_TYPE_MQTT_SUB, 'bridge_mqtt_sub'). @@ -625,32 +628,42 @@ on_resource_destroy(ResId, #{<<"pool">> := PoolName}) -> error({{?RESOURCE_TYPE_MQTT, ResId}, destroy_failed}) end. -on_action_create_data_to_mqtt_broker(_Id, #{<<"pool">> := PoolName, - <<"forward_topic">> := ForwardTopic, - <<"payload_tmpl">> := PayloadTmpl}) -> +on_action_create_data_to_mqtt_broker(ActId, Opts = #{<<"pool">> := PoolName, + <<"forward_topic">> := ForwardTopic, + <<"payload_tmpl">> := PayloadTmpl}) -> ?LOG(info, "Initiating Action ~p.", [?FUNCTION_NAME]), PayloadTks = emqx_rule_utils:preproc_tmpl(PayloadTmpl), TopicTks = case ForwardTopic == <<"">> of true -> undefined; false -> emqx_rule_utils:preproc_tmpl(ForwardTopic) end, - fun(Msg, _Env = #{id := Id, clientid := From, flags := Flags, - topic := Topic, timestamp := TimeStamp, qos := QoS}) -> - Topic1 = case TopicTks =:= undefined of - true -> Topic; - false -> emqx_rule_utils:proc_tmpl(TopicTks, Msg) - end, - BrokerMsg = #message{id = Id, - qos = QoS, - from = From, - flags = Flags, - topic = Topic1, - payload = format_data(PayloadTks, Msg), - timestamp = TimeStamp}, - ecpool:with_client(PoolName, fun(BridgePid) -> - BridgePid ! {deliver, rule_engine, BrokerMsg} - end) - end. + Opts. + +on_action_data_to_mqtt_broker(Msg, _Env = + #{id := Id, clientid := From, flags := Flags, + topic := Topic, timestamp := TimeStamp, qos := QoS, + ?BINDING_KEYS := #{ + 'ActId' := ActId, + 'PoolName' := PoolName, + 'TopicTks' := TopicTks, + 'PayloadTks' := PayloadTks + }}) -> + Topic1 = case TopicTks =:= undefined of + true -> Topic; + false -> emqx_rule_utils:proc_tmpl(TopicTks, Msg) + end, + BrokerMsg = #message{id = Id, + qos = QoS, + from = From, + flags = Flags, + topic = Topic1, + payload = format_data(PayloadTks, Msg), + timestamp = TimeStamp}, + ecpool:with_client(PoolName, + fun(BridgePid) -> + BridgePid ! {deliver, rule_engine, BrokerMsg} + end), + emqx_rule_metrics:inc_actions_success(ActId). format_data([], Msg) -> emqx_json:encode(Msg); diff --git a/apps/emqx_bridge_mqtt/src/emqx_bridge_worker.erl b/apps/emqx_bridge_mqtt/src/emqx_bridge_worker.erl index 2078fe41a..768cd3258 100644 --- a/apps/emqx_bridge_mqtt/src/emqx_bridge_worker.erl +++ b/apps/emqx_bridge_mqtt/src/emqx_bridge_worker.erl @@ -16,7 +16,7 @@ %% @doc Bridge works in two layers (1) batching layer (2) transport layer %% The `bridge' batching layer collects local messages in batches and sends over -%% to remote MQTT node/cluster via `connetion' transport layer. +%% to remote MQTT node/cluster via `connection' transport layer. %% In case `REMOTE' is also an EMQX node, `connection' is recommended to be %% the `gen_rpc' based implementation `emqx_bridge_rpc'. Otherwise `connection' %% has to be `emqx_bridge_mqtt'. @@ -98,6 +98,9 @@ , ensure_subscription_absent/2 ]). +%% Internal +-export([msg_marshaller/1]). + -export_type([ config/0 , batch/0 , ack_ref/0 @@ -232,13 +235,10 @@ init(Config) -> State = init_opts(Config), Topics = [iolist_to_binary(T) || T <- Forwards], Subs = check_subscriptions(Subscriptions), - ConnectConfig = get_conn_cfg(Config), - ConnectFun = fun(SubsX) -> - emqx_bridge_connect:start(ConnectModule, ConnectConfig#{subscriptions => SubsX}) - end, + ConnectCfg = get_conn_cfg(Config), self() ! idle, {ok, idle, State#{connect_module => ConnectModule, - connect_fun => ConnectFun, + connect_cfg => ConnectCfg, forwards => Topics, subscriptions => Subs, replayq => Queue @@ -276,7 +276,7 @@ open_replayq(Config) -> false -> #{dir => Dir, seg_bytes => SegBytes, max_total_size => MaxTotalSize} end, replayq:open(QueueConfig#{sizer => fun emqx_bridge_msg:estimate_size/1, - marshaller => fun msg_marshaller/1}). + marshaller => fun ?MODULE:msg_marshaller/1}). check_subscriptions(Subscriptions) -> lists:map(fun({Topic, QoS}) -> @@ -433,10 +433,11 @@ is_topic_present(Topic, Topics) -> do_connect(#{forwards := Forwards, subscriptions := Subs, - connect_fun := ConnectFun, + connect_module := ConnectModule, + connect_cfg := ConnectCfg, name := Name} = State) -> ok = subscribe_local_topics(Forwards, Name), - case ConnectFun(Subs) of + case emqx_bridge_connect:start(ConnectModule, ConnectCfg#{subscriptions => Subs}) of {ok, Conn} -> ?LOG(info, "Bridge ~p is connecting......", [Name]), {ok, eval_bridge_handler(State#{connection => Conn}, connected)}; diff --git a/apps/emqx_coap/src/emqx_coap_server.erl b/apps/emqx_coap/src/emqx_coap_server.erl index d3ed1a96e..0d571fac3 100644 --- a/apps/emqx_coap/src/emqx_coap_server.erl +++ b/apps/emqx_coap/src/emqx_coap_server.erl @@ -22,6 +22,12 @@ , stop/1 ]). +-export([ start_listener/1 + , start_listener/3 + , stop_listener/1 + , stop_listener/2 + ]). + %%-------------------------------------------------------------------- %% APIs %%-------------------------------------------------------------------- diff --git a/apps/emqx_dashboard/src/emqx_dashboard.erl b/apps/emqx_dashboard/src/emqx_dashboard.erl index 292f2c453..cbf0d81d5 100644 --- a/apps/emqx_dashboard/src/emqx_dashboard.erl +++ b/apps/emqx_dashboard/src/emqx_dashboard.erl @@ -25,6 +25,11 @@ , stop_listeners/0 ]). +%% for minirest +-export([ filter/1 + , is_authorized/1 + ]). + -define(APP, ?MODULE). %%-------------------------------------------------------------------- @@ -81,7 +86,9 @@ listener_name(Proto) -> http_handlers() -> Plugins = lists:map(fun(Plugin) -> Plugin#plugin.name end, emqx_plugins:list()), - [{"/api/v4/", minirest:handler(#{apps => Plugins, filter => fun filter/1}),[{authorization, fun is_authorized/1}]}]. + [{"/api/v4/", + minirest:handler(#{apps => Plugins, filter => fun ?MODULE:filter/1}), + [{authorization, fun ?MODULE:is_authorized/1}]}]. %%-------------------------------------------------------------------- %% Basic Authorization diff --git a/apps/emqx_exhook/.github/workflows/run_test_cases.yaml b/apps/emqx_exhook/.github/workflows/run_test_cases.yaml index fed706984..9970ed321 100644 --- a/apps/emqx_exhook/.github/workflows/run_test_cases.yaml +++ b/apps/emqx_exhook/.github/workflows/run_test_cases.yaml @@ -2,7 +2,7 @@ name: Run test cases on: [push, pull_request] -jobs: +jobs: run_test_cases: runs-on: ubuntu-latest @@ -11,21 +11,18 @@ jobs: steps: - uses: actions/checkout@v1 - - uses: actions/setup-java@v1 - with: - java-version: '8.0.x' - java-package: jdk - - name: run test cases + - name: Code dialyzer run: | - make eunit + make xref + make dialyzer + - name: Run test cases + run: | + make eunit + make proper make ct make cover - uses: actions/upload-artifact@v1 - if: always() + if: failure() with: name: logs path: _build/test/logs - - uses: actions/upload-artifact@v1 - with: - name: cover - path: _build/test/cover diff --git a/apps/emqx_exhook/.gitignore b/apps/emqx_exhook/.gitignore index 9ecba8017..da1f0db23 100644 --- a/apps/emqx_exhook/.gitignore +++ b/apps/emqx_exhook/.gitignore @@ -23,3 +23,7 @@ data/ *.pyc .DS_Store *.class +Mnesia.nonode@nohost/ +src/emqx_exhook_pb.erl +src/emqx_exhook_v_1_hook_provider_client.erl +src/emqx_exhook_v_1_hook_provider_bhvr.erl diff --git a/apps/emqx_exhook/README.md b/apps/emqx_exhook/README.md index 9d4ccd81f..216c39275 100644 --- a/apps/emqx_exhook/README.md +++ b/apps/emqx_exhook/README.md @@ -1,75 +1,39 @@ -# emqx_extension_hook +# emqx_exhook -The `emqx_extension_hook` extremly enhance the extensibility for EMQ X. It allow using an others programming language to mount the hooks intead of erlang. +The `emqx_exhook` extremly enhance the extensibility for EMQ X. It allow using an others programming language to mount the hooks intead of erlang. ## Feature -- [x] Support `python` and `java`. -- [x] Support all hooks of emqx. +- [x] Based on gRPC, it brings a very wide range of applicability - [x] Allows you to use the return value to extend emqx behavior. -We temporarily no plans to support other languages. Plaease open a issue if you have to use other programming languages. - ## Architecture ``` - EMQ X Third-party Runtimes -+========================+ +====================+ -| Extension | | | -| +----------------+ | Hooks | Python scripts / | -| | Drivers | ------------------> | Java Classes / | -| +----------------+ | (pipe) | Others ... | -| | | | -+========================+ +====================+ +EMQ X Third-party Runtime ++========================+ +========+==========+ +| ExHook | | | | +| +----------------+ | gRPC | gRPC | User's | +| | gPRC Client | ------------------> | Server | Codes | +| +----------------+ | (HTTP/2) | | | +| | | | | ++========================+ +========+==========+ ``` -## Drivers +## Usage -### Python +### gRPC service -***Requirements:*** +See: `priv/protos/exhook.proto` -- It requires the emqx hosted machine has Python3 Runtimes (not support python2) -- The `python3` executable commands in your shell +### CLI -***Examples:*** +## Example -See `test/scripts/main.py` +## Recommended gRPC Framework -### Java +See: https://github.com/grpc-ecosystem/awesome-grpc -***Requirements:*** +## Thanks -- It requires the emqx hosted machine has Java 8+ Runtimes -- An executable commands in your shell, i,g: `java` - -***Examples:*** - -See `test/scripts/Main.java` - -## Configurations - -| Name | Data Type | Options | Default | Description | -| ------------------- | --------- | ------------------------------------- | ---------------- | -------------------------------- | -| drivers | Enum | `python3`
`java` | `python3` | Drivers type | -| .path | String | - | `data/extension` | The codes/library search path | -| .call_timeout | Duration | - | `5s` | Function call timeout | -| .pool_size | Integer | - | `8` | The pool size for the driver | -| .init_module | String | - | main | The module name for initial call | - -## SDK - -See `sdk/README.md` - -## Known Issues or TODOs - -**Configurable Log System** - -- use stderr to print logs to the emqx console instead of stdout. An alternative is to print the logs to a file. -- The Java driver can not redirect the `stderr` stream to erlang vm on Windows platform. - -## Reference - -- [erlport](https://github.com/hdima/erlport) -- [Eexternal Term Format](http://erlang.org/doc/apps/erts/erl_ext_dist.html) -- [The Ports Tutorial of Erlang](http://erlang.org/doc/tutorial/c_port.html) +- [grpcbox](https://github.com/tsloughter/grpcbox) diff --git a/apps/emqx_exhook/docs/design.md b/apps/emqx_exhook/docs/design.md index 1bf74723c..671e240cc 100644 --- a/apps/emqx_exhook/docs/design.md +++ b/apps/emqx_exhook/docs/design.md @@ -2,254 +2,115 @@ ## 动机 -增强系统的扩展性。包含的目的有: +在 EMQ X Broker v4.1-v4.2 中,我们发布了 2 个插件来扩展 emqx 的编程能力: -- 完全支持各种钩子,能够根据其返回值修改 EMQ X 或者 Client 的行为。 - - 例如 `auth/acl`:可以查询数据库或者执行某种算法校验操作权限。然后返回 `false` 表示 `认证/ACL` 失败。 - - 例如 `message.publish`:可以解析 `消息/主题` 并将其存储至数据库中。 +1. `emqx-extension-hook` 提供了使用 Java, Python 向 Broker 挂载钩子的功能 +2. `emqx-exproto` 提供了使用 Java,Python 编写用户自定义协议接入插件的功能 -- 支持多种语言的扩展;并包含该语言的示例程序。 - - python - - webhook - - Java - - Lua - - c,go,..... -- 热操作 - - 允许在插件运行过程中,添加和移除 `Driver`。 +但在后续的支持中发现许多难以处理的问题: -- 需要 CLI ,甚至 API 来管理 `Driver` +1. 有大量的编程语言需要支持,需要编写和维护如 Go, JavaScript, Lua.. 等语言的驱动。 +2. `erlport` 使用的操作系统的管道进行通信,这让用户代码只能部署在和 emqx 同一个操作系统上。部署方式受到了极大的限制。 +3. 用户程序的启动参数直接打包到 Broker 中,导致用户开发无法实时的进行调试,单步跟踪等。 +4. `erlport` 会占用 `stdin` `stdout`。 -注:`message` 类钩子仅包括在企业版中。 +因此,我们计划重构这部分的实现,其中主要的内容是: +1. 使用 `gRPC` 替换 `erlport`。 +2. 将 `emqx-extension-hook` 重命名为 `emqx-exhook` + + +旧版本的设计参考:[emqx-extension-hook design in v4.2.0](https://github.com/emqx/emqx-exhook/blob/v4.2.0/docs/design.md) ## 设计 架构如下: ``` - EMQ X Third-party Runtimes -+========================+ +====================+ -| Extension | | | -| +----------------+ | Hooks | Python scripts / | -| | Drivers | ------------------> | Java Classes / | -| +----------------+ | (pipe) | Others ... | -| | | | -+========================+ +====================+ + EMQ X ++========================+ +========+==========+ +| ExHook | | | | +| +----------------+ | gRPC | gRPC | User's | +| | gRPC Client | ------------------> | Server | Codes | +| +----------------+ | (HTTP/2) | | | +| | | | | ++========================+ +========+==========+ +``` + +`emqx-exhook` 通过 gRPC 的方式向用户部署的 gRPC 服务发送钩子的请求,并处理其返回的值。 + + +和 emqx 原生的钩子一致,emqx-exhook 也支持链式的方式计算和返回: + + + +### gRPC 服务示例 + +用户需要实现的方法,和数据类型的定义在 `priv/protos/exhook.proto` 文件中。例如,其支持的接口有: + +```protobuff +syntax = "proto3"; + +package emqx.exhook.v1; + +service HookProvider { + + rpc OnProviderLoaded(ProviderLoadedRequest) returns (LoadedResponse) {}; + + rpc OnProviderUnloaded(ProviderUnloadedRequest) returns (EmptySuccess) {}; + + rpc OnClientConnect(ClientConnectRequest) returns (EmptySuccess) {}; + + rpc OnClientConnack(ClientConnackRequest) returns (EmptySuccess) {}; + + rpc OnClientConnected(ClientConnectedRequest) returns (EmptySuccess) {}; + + rpc OnClientDisconnected(ClientDisconnectedRequest) returns (EmptySuccess) {}; + + rpc OnClientAuthenticate(ClientAuthenticateRequest) returns (ValuedResponse) {}; + + rpc OnClientCheckAcl(ClientCheckAclRequest) returns (ValuedResponse) {}; + + rpc OnClientSubscribe(ClientSubscribeRequest) returns (EmptySuccess) {}; + + rpc OnClientUnsubscribe(ClientUnsubscribeRequest) returns (EmptySuccess) {}; + + rpc OnSessionCreated(SessionCreatedRequest) returns (EmptySuccess) {}; + + rpc OnSessionSubscribed(SessionSubscribedRequest) returns (EmptySuccess) {}; + + rpc OnSessionUnsubscribed(SessionUnsubscribedRequest) returns (EmptySuccess) {}; + + rpc OnSessionResumed(SessionResumedRequest) returns (EmptySuccess) {}; + + rpc OnSessionDiscarded(SessionDiscardedRequest) returns (EmptySuccess) {}; + + rpc OnSessionTakeovered(SessionTakeoveredRequest) returns (EmptySuccess) {}; + + rpc OnSessionTerminated(SessionTerminatedRequest) returns (EmptySuccess) {}; + + rpc OnMessagePublish(MessagePublishRequest) returns (ValuedResponse) {}; + + rpc OnMessageDelivered(MessageDeliveredRequest) returns (EmptySuccess) {}; + + rpc OnMessageDropped(MessageDroppedRequest) returns (EmptySuccess) {}; + + rpc OnMessageAcked(MessageAckedRequest) returns (EmptySuccess) {}; +} ``` ### 配置文件示例 -#### 驱动 配置 - -```properties -## Driver type +``` +## 配置 gRPC 服务地址 (HTTP) ## -## Exmaples: -## - python3 --- 仅配置 python3 -## - python3, java, webhook --- 配置多个 Driver -exhook.dirvers = python3, java, webhook +## s1 为服务器的名称 +exhook.server.s1.url = http://127.0.0.1:9001 -## --- 具体 driver 的配置详情 - -## Python -exhook.dirvers.python3.path = data/extension/python -exhook.dirvers.python3.call_timeout = 5s -exhook.dirvers.python3.pool_size = 8 - -## java -exhook.drivers.java.path = data/extension/java -... -``` - -#### 钩子配置 - -钩子支持配置在配置文件中,例如: - -```properties -exhook.rule.python3.client.connected = {"module": "client", "callback": "on_client_connected"} -exhook.rule.python3.message.publish = {"module": "client", "callback": "on_client_connected", "topics": ["#", "t/#"]} -``` - -***已废弃!!(冗余)*** - - -### 驱动抽象 - -#### APIs - -| 方法名 | 说明 | 入参 | 返回 | -| ------------------------ | -------- | ------ | ------ | -| `init` | 初始化 | - | 见下表 | -| `deinit` | 销毁 | - | - | -| `xxx `*(由init函数定义)* | 钩子回调 | 见下表 | 见下表 | - - - -##### init 函数规格 - -```erlang -%% init 函数 -%% HookSpec : 为用户在脚本中的 初始化函数指定的;他会与配置文件中的内容作为默认值,进行合并 -%% 该参数的目的,用于 EMQ X 判断需要执行哪些 Hook 和 如何执行 Hook -%% State : 为用户自己管理的数据内容,EMQ X 不关心它,只来回透传 -init() -> {HookSpec, State}. - -%% 例如: -{[{client_connect, callback_m(), callback_f(),#{}, {}}]} - -%%-------------------------------------------------------------- -%% Type Defines - --tpye hook_spec() :: [{hookname(), callback_m(), callback_f(), hook_opts()}]. - --tpye state :: any(). - --type hookname() :: client_connect - | client_connack - | client_connected - | client_disconnected - | client_authenticate - | client_check_acl - | client_subscribe - | client_unsubscribe - | session_created - | session_subscribed - | session_unsubscribed - | session_resumed - | session_discarded %% TODO: Should squash to `terminated` ? - | session_takeovered %% TODO: Should squash to `terminated` ? - | session_terminated - | message_publish - | message_delivered - | message_acked - | message_dropped. - --type callback_m() :: atom(). -- 回调的模块名称;python 为脚本文件名称;java 为类名;webhook 为 URI 地址 - --type callback_f() :: atom(). -- 回调的方法名称;python,java 等为方法名;webhook 为资源地址 - --tpye hook_opts() :: [{hook_key(), any()}]. -- 配置项;配置该项钩子的行为 - --type hook_key() :: topics | ... -``` - - - -##### deinit 函数规格 - -``` erlang -%% deinit 函数;不关心返回的任何内容 -deinit() -> any(). -``` - - - -##### 回调函数规格 - -| 钩子 | 入参 | 返回 | -| -------------------- | ----------------------------------------------------- | --------- | -| client_connect | `connifno`
`props` | - | -| client_connack | `connifno`
`rc`
`props` | - | -| client_connected | `clientinfo`
| - | -| client_disconnected | `clientinfo`
`reason` | - | -| client_authenticate | `clientinfo`
`result` | `result` | -| client_check_acl | `clientinfo`
`pubsub`
`topic`
`result` | `result` | -| client_subscribe | `clientinfo`
`props`
`topicfilters` | - | -| client_unsubscribe | `clientinfo`
`props`
`topicfilters` | - | -| session_created | `clientinfo` | - | -| session_subscribed | `clientinfo`
`topic`
`subopts` | - | -| session_unsubscribed | `clientinfo`
`topic` | - | -| session_resumed | `clientinfo` | - | -| session_discared | `clientinfo` | - | -| session_takeovered | `clientinfo` | - | -| session_terminated | `clientinfo`
`reason` | - | -| message_publish | `messsage` | `message` | -| message_delivered | `clientinfo`
`message` | - | -| message_dropped | `message` | - | -| message_acked | `clientinfo`
`message` | - | - - - -上表中包含数据格式为: - -```erlang --type conninfo :: [ {node, atom()} - , {clientid, binary()} - , {username, binary()} - , {peerhost, binary()} - , {sockport, integer()} - , {proto_name, binary()} - , {proto_ver, integer()} - , {keepalive, integer()} - ]. - --type clientinfo :: [ {node, atom()} - , {clientid, binary()} - , {username, binary()} - , {password, binary()} - , {peerhost, binary()} - , {sockport, integer()} - , {protocol, binary()} - , {mountpoint, binary()} - , {is_superuser, boolean()} - , {anonymous, boolean()} - ]. - --type message :: [ {node, atom()} - , {id, binary()} - , {qos, integer()} - , {from, binary()} - , {topic, binary()} - , {payload, binary()} - , {timestamp, integer()} - ]. - --type rc :: binary(). --type props :: [{key(), value()}] - --type topics :: [topic()]. --type topic :: binary(). --type pubsub :: publish | subscribe. --type result :: true | false. -``` - - - -### 统计 - -在驱动运行过程中,应有对每种钩子调用计数,例如: - -``` -exhook.python3.check_acl 10 -``` - - - -### 管理 - -**CLI 示例:** - - - -**列出所有的驱动** - -``` -./bin/emqx_ctl exhook dirvers list -Drivers(xxx=yyy) -Drivers(aaa=bbb) -``` - - - -**开关驱动** - -``` -./bin/emqx_ctl exhook drivers enable python3 -ok - -./bin/emqx_ctl exhook drivers disable python3 -ok - -./bin/emqx_ctl exhook drivers stats -python3.client_connect 123 -webhook.check_acl 20 +## 配置 gRPC 服务地址 (HTTPS) +## +## s2 为服务器名称 +exhook.server.s2.url = https://127.0.0.1:9002 +exhook.server.s2.cacertfile = ca.pem +exhook.server.s2.certfile = cert.pem +exhook.server.s2.keyfile = key.pem ``` diff --git a/apps/emqx_exhook/docs/introduction.md b/apps/emqx_exhook/docs/introduction.md deleted file mode 100644 index f4cdbd877..000000000 --- a/apps/emqx_exhook/docs/introduction.md +++ /dev/null @@ -1,84 +0,0 @@ -## 简介 - -`emqx-extension-hook` 插件用于提供钩子(Hook)的多语言支持。它能够允许其他的语言,例如:Python,Java 等,能够直接表达如何挂载钩子,和处理相应的钩子事件。 - -该插件给 EMQ X 带来的扩展性十分的强大,甚至于所有基于钩子的插件都可以通过其他编程语言实现。唯一不同的是在性能上肯定有一定的降低。 - -目前,一些常见的场景有: - -- 通过 `client.authenticate` 钩子,使用其他编程语言查询数据库,判断该客户端是否具有接入的权限。 -- 通过 `client.check_acl` 钩子,使用其他编程语言查询数据库,实现发布/订阅的权限控制逻辑。 -- 通过 `message` 类的钩子,实现消息收发的控制和数据格式转换。 -- 获取客户端所有的事件,将其存储进三方的日志、或数据平台中。 - -**声明:当前仅实现了 Python、Java 的支持** - -**声明:message 类钩子功能仅包含在企业版当中** - -### 要求 - -EMQ X 发行包中不包含其他语言的运行环境。它要求: - -- 宿主机需包含其他编程语言对应的执行环境。 -- 必须将源码(或编译后的代码)、资源文件等,放到 `emqx-extension-hook` 指示的路径。 -- 代码的实现,若包含三方依赖、库等,它应该包含在 `emqx-extension-hook` 对其的搜索路径中。 - - -### 架构 - -`emqx-extension-hook` 是 EMQ X 的一个插件,它主要包括: - -1. 驱动的管理。例如:如何启动/停止某个驱动。 -2. 事件的分发。例如:根据各个驱动所注册的钩子列表的不同,向各个驱动分发事件,传递返回值等。 -3. 预置了驱动的实现。包括 Python 和 Java 驱动的实现,和方便用户集成开发的 SDK 代码包。 - -其架构图如下: - -``` - EMQ X Third-party Runtimes -+========================+ +====================+ -| Extension | | | -| +----------------+ | Hooks | Python scripts / | -| | Drivers | ------------------> | Java Classes / | -| +----------------+ | (pipe) | Others ... | -| | | | -+========================+ +====================+ -``` - -图中表明,由 Client 产生的所有的事件,例如:连接、发布、订阅等,都会由 `emqx-extension-hook`插件分发给下面的各个 `驱动(Driver)`;而,驱动则负责如何与三方运行时的进行通信。 - -广义上的驱动(Driver)可以分为两类: - -1. 编程语言类。 -2. 服务类。例如:HTTP 就属于此类。 - -`emqx-extension-hook` 并不关心驱动实际的类型和实现,只要其实现了对应的接口即可。 - - -#### 驱动 - -本文中,只有未经限定说明的驱动,都是指编程语言类的驱动。 - -编程语言类驱动是基于 [Erlang - Port](http://erlang.org/doc/tutorial/c_port.html) 进行实现。它本质上是由 `emqx-extension-hook` 是启动一个其他语言的程序,并使用管道(Pipe)实现两个进程间的通信。 - - -此类驱动的实现包括两部分的内容: - -1. Erlang 侧的实现,它包含如何启动其他语言的运行时系统、和分发请求、处理结果等。 -2. 其他语言侧的实现。它包含如何和 Erlang 虚拟机通信,如何执行函数调用等。 - -如: - -``` - Erlang VM Third Runtimes (e.g: Java VM) - +===========+=========+ +=========+================+ - | Extension | Driver | <=====> | Driver | User's Codes | - +===========+=========+ +=========+================+ -``` - -而,对于基于服务的驱动,原理就很简单了。以 HTTP 为例,它的实现仅需要一个 HTTP 客户端、和指定服务端返回的数据格式即可。 - -### 集成与调试 - -参见 SDK 规范、和对应语言的开发手册 - diff --git a/apps/emqx_exhook/docs/sdk-specification.md b/apps/emqx_exhook/docs/sdk-specification.md deleted file mode 100644 index 7593bc9b6..000000000 --- a/apps/emqx_exhook/docs/sdk-specification.md +++ /dev/null @@ -1,79 +0,0 @@ -## SDK 规范 - -### 动机 - -SDK 的目的在于方便用户使用 IDE 集成开发、和模拟调试。 - -### 位置 - -``` - +------------------+ - | User's Codes | - +------------------+ - | SDK | <==== The SDK Located - +------------------+ - | Raw APIs | - +------------------+ - | Driver | - +==================+ - || - +==================+ - | EMQ X Plugin | - +------------------+ -``` - -因此,SDK 的作用在于封装底层的比较晦涩的数据格式和方法,屏蔽底层细节。直接提供优化 API 供用户使用。 - - -### 实现要求 - -**声明:** stdin, stdout 已用于和 EMQ X 通信,请不要使用。stderr 用于日志输出。 - -#### 基础项 - -1. 必须将原始的 `init` `deinit`函数进行封装,方便用户: - - 配置需要挂载的钩子列表 - - 定义用户自己的初始化和销毁的内容 -2. 必须将回调函数的各个松散的数据类型,封装成类或某种结构化类型。 -3. 必须要有对应的开发、部署文档说明 - -#### 高级项 - -1. 应能方便用户能在 IDE 中进行,集成和开发 -2. 应提供集成测试用的模拟代码。 - - 例如,生成模拟的数据,发送至用户的程序,方便直接断点调试 - - -### 部署结构 - -#### 代码依赖结构 - -从部署的角度看,代码的依赖关系为: - -1. 用户代码: - * 一定会依赖 SDK - * 可能会依赖 某个位置的三方/系统库 -2. SDK 代码: - * 只能依赖 erlport -3. 基础通信库 - * 无依赖 - -#### 部署 - -从文件存放的位置来看,一个标准的部署结构为: - -``` -emqx -| -|--- data -|------- extension -|---------- -|--------------- -|---------- -| -|---------- -|--------------- -|---------- -``` - -它表达了:在 `data/extension` 目录下安装了两个 SDK,并且用户都基于 SDK 编写了其回调的代码模块。 diff --git a/apps/emqx_exhook/etc/emqx_exhook.conf b/apps/emqx_exhook/etc/emqx_exhook.conf new file mode 100644 index 000000000..f6f5213f7 --- /dev/null +++ b/apps/emqx_exhook/etc/emqx_exhook.conf @@ -0,0 +1,15 @@ +##==================================================================== +## EMQ X Hooks +##==================================================================== + +##-------------------------------------------------------------------- +## Server Address + +## The gRPC server url +## +## exhook.server.$name.url = url() +exhook.server.default.url = http://127.0.0.1:9000 + +#exhook.server.default.ssl.cacertfile = {{ platform_etc_dir }}/certs/cacert.pem +#exhook.server.default.ssl.certfile = {{ platform_etc_dir }}/certs/cert.pem +#exhook.server.default.ssl.keyfile = {{ platform_etc_dir }}/certs/key.pem diff --git a/apps/emqx_exhook/etc/emqx_extension_hook.conf b/apps/emqx_exhook/etc/emqx_extension_hook.conf deleted file mode 100644 index a4f531108..000000000 --- a/apps/emqx_exhook/etc/emqx_extension_hook.conf +++ /dev/null @@ -1,24 +0,0 @@ -##==================================================================== -## EMQ X Hooks -##==================================================================== - -##-------------------------------------------------------------------- -## Driver confs - -## Setup the supported drivers -## -## Value: python3 | java -exhook.drivers = python3 - -## Search path for scripts/library -## -exhook.drivers.python3.path = {{ platform_data_dir }}/extension/ - -## Call timeout -## -## Value: Duration -##exhook.drivers.python3.call_timeout = 5s - -## Initial module name -## -##exhook.drivers.python3.init_module = main diff --git a/apps/emqx_exhook/include/emqx_extension_hook.hrl b/apps/emqx_exhook/include/emqx_exhook.hrl similarity index 87% rename from apps/emqx_exhook/include/emqx_extension_hook.hrl rename to apps/emqx_exhook/include/emqx_exhook.hrl index 18260754e..8a404ca39 100644 --- a/apps/emqx_exhook/include/emqx_extension_hook.hrl +++ b/apps/emqx_exhook/include/emqx_exhook.hrl @@ -14,9 +14,9 @@ %% limitations under the License. %%-------------------------------------------------------------------- --ifndef(EMQX_EXTENSION_HOOK_HRL). --define(EMQX_EXTENSION_HOOK_HRL, true). +-ifndef(EMQX_EXHOOK_HRL). +-define(EMQX_EXHOOK_HRL, true). --define(APP, emqx_extension_hook). +-define(APP, emqx_exhook). -endif. diff --git a/apps/emqx_exhook/priv/emqx_exhook.schema b/apps/emqx_exhook/priv/emqx_exhook.schema new file mode 100644 index 000000000..2a926b968 --- /dev/null +++ b/apps/emqx_exhook/priv/emqx_exhook.schema @@ -0,0 +1,38 @@ +%%-*- mode: erlang -*- + +{mapping, "exhook.server.$name.url", "emqx_exhook.servers", [ + {datatype, string} +]}. + +{mapping, "exhook.server.$name.ssl.cacertfile", "emqx_exhook.servers", [ + {datatype, string} +]}. + +{mapping, "exhook.server.$name.ssl.certfile", "emqx_exhook.servers", [ + {datatype, string} +]}. + +{mapping, "exhook.server.$name.ssl.keyfile", "emqx_exhook.servers", [ + {datatype, string} +]}. + +{translation, "emqx_exhook.servers", fun(Conf) -> + Filter = fun(Opts) -> [{K, V} || {K, V} <- Opts, V =/= undefined] end, + ServerOptions = fun(Prefix) -> + case http_uri:parse(cuttlefish:conf_get(Prefix ++ ".url", Conf)) of + {ok, {http, _, Host, Port, _, _}} -> + [{scheme, http}, {host, Host}, {port, Port}]; + {ok, {https, _, Host, Port, _, _}} -> + [{scheme, https}, {host, Host}, {port, Port}, + {ssl_options, + Filter([{ssl, true}, + {certfile, cuttlefish:conf_get(Prefix ++ ".ssl.certfile", Conf)}, + {keyfile, cuttlefish:conf_get(Prefix ++ ".ssl.keyfile", Conf)}, + {cacertfile, cuttlefish:conf_get(Prefix ++ ".ssl.cacertfile", Conf)} + ])}]; + _ -> error(invalid_server_options) + end + end, + [{list_to_atom(Name), ServerOptions("exhook.server." ++ Name)} + || {["exhook", "server", Name, "url"], _} <- cuttlefish_variable:filter_by_prefix("exhook.server", Conf)] +end}. diff --git a/apps/emqx_exhook/priv/emqx_extension_hook.schema b/apps/emqx_exhook/priv/emqx_extension_hook.schema deleted file mode 100644 index 677b57726..000000000 --- a/apps/emqx_exhook/priv/emqx_extension_hook.schema +++ /dev/null @@ -1,43 +0,0 @@ -%%-*- mode: erlang -*- - -{mapping, "exhook.drivers", "emqx_extension_hook.drivers", [ - {datatype, string} -]}. - -{mapping, "exhook.drivers.$name.$key", "emqx_extension_hook.drivers", [ - {datatype, string} -]}. - -{translation, "emqx_extension_hook.drivers", fun(Conf) -> - - Filter = fun(Opts) -> [{K, V} || {K, V} <- Opts, V =/= undefined] end, - - Duration = fun(S) -> - case cuttlefish_duration:parse(S, ms) of - Ms when is_integer(Ms) -> Ms; - {error, R} -> error(R) - end - end, - Integer = fun(S) -> list_to_integer(S) end, - - Atom = fun(S) -> list_to_atom(S) end, - - Python = fun(Prefix) -> - [{init_module, Atom(cuttlefish:conf_get(Prefix ++ ".init_module", Conf, "main"))}, %% dirver - {python_path, cuttlefish:conf_get(Prefix ++ ".path", Conf, undefined)}, - {call_timeout, Duration(cuttlefish:conf_get(Prefix ++ ".call_timeout", Conf, "5s"))}] - end, - - Java = fun(Prefix) -> - [{init_module, Atom(cuttlefish:conf_get(Prefix ++ ".init_module", Conf, "Main"))}, %% dirver - {java_path, cuttlefish:conf_get(Prefix ++ ".path", Conf, undefined)}, - {call_timeout, Duration(cuttlefish:conf_get(Prefix ++ ".call_timeout", Conf, "5s"))}] - end, - - Options = fun(python) -> Filter(Python("exhook.drivers.python")); - (python3) -> Filter(Python("exhook.drivers.python3")); - (java) -> Filter(Java("exhook.drivers.java")); - (_) -> error(not_supported_drivers_type) - end, - [{Atom(Name), Options(Atom(Name))} || Name <- string:tokens(cuttlefish:conf_get("exhook.drivers", Conf), ",")] -end}. diff --git a/apps/emqx_exhook/priv/protos/exhook.proto b/apps/emqx_exhook/priv/protos/exhook.proto new file mode 100644 index 000000000..8dc9641b9 --- /dev/null +++ b/apps/emqx_exhook/priv/protos/exhook.proto @@ -0,0 +1,395 @@ +//------------------------------------------------------------------------------ +// Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//------------------------------------------------------------------------------ + +syntax = "proto3"; + +package emqx.exhook.v1; + +service HookProvider { + + rpc OnProviderLoaded(ProviderLoadedRequest) returns (LoadedResponse) {}; + + rpc OnProviderUnloaded(ProviderUnloadedRequest) returns (EmptySuccess) {}; + + rpc OnClientConnect(ClientConnectRequest) returns (EmptySuccess) {}; + + rpc OnClientConnack(ClientConnackRequest) returns (EmptySuccess) {}; + + rpc OnClientConnected(ClientConnectedRequest) returns (EmptySuccess) {}; + + rpc OnClientDisconnected(ClientDisconnectedRequest) returns (EmptySuccess) {}; + + rpc OnClientAuthenticate(ClientAuthenticateRequest) returns (ValuedResponse) {}; + + rpc OnClientCheckAcl(ClientCheckAclRequest) returns (ValuedResponse) {}; + + rpc OnClientSubscribe(ClientSubscribeRequest) returns (EmptySuccess) {}; + + rpc OnClientUnsubscribe(ClientUnsubscribeRequest) returns (EmptySuccess) {}; + + rpc OnSessionCreated(SessionCreatedRequest) returns (EmptySuccess) {}; + + rpc OnSessionSubscribed(SessionSubscribedRequest) returns (EmptySuccess) {}; + + rpc OnSessionUnsubscribed(SessionUnsubscribedRequest) returns (EmptySuccess) {}; + + rpc OnSessionResumed(SessionResumedRequest) returns (EmptySuccess) {}; + + rpc OnSessionDiscarded(SessionDiscardedRequest) returns (EmptySuccess) {}; + + rpc OnSessionTakeovered(SessionTakeoveredRequest) returns (EmptySuccess) {}; + + rpc OnSessionTerminated(SessionTerminatedRequest) returns (EmptySuccess) {}; + + rpc OnMessagePublish(MessagePublishRequest) returns (ValuedResponse) {}; + + rpc OnMessageDelivered(MessageDeliveredRequest) returns (EmptySuccess) {}; + + rpc OnMessageDropped(MessageDroppedRequest) returns (EmptySuccess) {}; + + rpc OnMessageAcked(MessageAckedRequest) returns (EmptySuccess) {}; +} + +//------------------------------------------------------------------------------ +// Request & Response +//------------------------------------------------------------------------------ + +message ProviderLoadedRequest { + + BrokerInfo broker = 1; +} + +message LoadedResponse { + + repeated HookSpec hooks = 1; +} + +message ProviderUnloadedRequest { } + +message ClientConnectRequest { + + ConnInfo conninfo = 1; + + // MQTT CONNECT packet's properties (MQTT v5.0) + // + // It should be empty on MQTT v3.1.1/v3.1 or others protocol + repeated Property props = 2; +} + +message ClientConnackRequest { + + ConnInfo conninfo = 1; + + string result_code = 2; + + repeated Property props = 3; +} + +message ClientConnectedRequest { + + ClientInfo clientinfo = 1; +} + +message ClientDisconnectedRequest { + + ClientInfo clientinfo = 1; + + string reason = 2; +} + +message ClientAuthenticateRequest { + + ClientInfo clientinfo = 1; + + bool result = 2; +} + +message ClientCheckAclRequest { + + ClientInfo clientinfo = 1; + + enum AclReqType { + + PUBLISH = 0; + + SUBSCRIBE = 1; + } + + AclReqType type = 2; + + string topic = 3; + + bool result = 4; +} + +message ClientSubscribeRequest { + + ClientInfo clientinfo = 1; + + repeated Property props = 2; + + repeated TopicFilter topic_filters = 3; +} + +message ClientUnsubscribeRequest { + + ClientInfo clientinfo = 1; + + repeated Property props = 2; + + repeated TopicFilter topic_filters = 3; +} + +message SessionCreatedRequest { + + ClientInfo clientinfo = 1; +} + +message SessionSubscribedRequest { + + ClientInfo clientinfo = 1; + + string topic = 2; + + SubOpts subopts = 3; +} + +message SessionUnsubscribedRequest { + + ClientInfo clientinfo = 1; + + string topic = 2; +} + +message SessionResumedRequest { + + ClientInfo clientinfo = 1; +} + +message SessionDiscardedRequest { + + ClientInfo clientinfo = 1; +} + +message SessionTakeoveredRequest { + + ClientInfo clientinfo = 1; +} + +message SessionTerminatedRequest { + + ClientInfo clientinfo = 1; + + string reason = 2; +} + +message MessagePublishRequest { + + Message message = 1; +} + +message MessageDeliveredRequest { + + ClientInfo clientinfo = 1; + + Message message = 2; +} + +message MessageDroppedRequest { + + Message message = 1; + + string reason = 2; +} + +message MessageAckedRequest { + + ClientInfo clientinfo = 1; + + Message message = 2; +} + +//------------------------------------------------------------------------------ +// Basic data types +//------------------------------------------------------------------------------ + +message EmptySuccess { } + +message ValuedResponse { + + // The responsed value type + // - ignore: Ignore the responsed value + // - contiune: Use the responsed value and execute the next hook + // - stop_and_return: Use the responsed value and stop the chain executing + enum ResponsedType { + + IGNORE = 0; + + CONTINUE = 1; + + STOP_AND_RETURN = 2; + } + + ResponsedType type = 1; + + oneof value { + + // Boolean result, used on the 'client.authenticate', 'client.check_acl' hooks + bool bool_result = 3; + + // Message result, used on the 'message.*' hooks + Message message = 4; + } +} + +message BrokerInfo { + + string version = 1; + + string sysdescr = 2; + + string uptime = 3; + + string datetime = 4; +} + +message HookSpec { + + // The registered hooks name + // + // Available value: + // "client.connect", "client.connack" + // "client.connected", "client.disconnected" + // "client.authenticate", "client.check_acl" + // "client.subscribe", "client.unsubscribe" + // + // "session.created", "session.subscribed" + // "session.unsubscribed", "session.resumed" + // "session.discarded", "session.takeovered" + // "session.terminated" + // + // "message.publish", "message.delivered" + // "message.acked", "message.dropped" + string name = 1; + + // The topic filters for message hooks + repeated string topics = 2; +} + +message ConnInfo { + + string node = 1; + + string clientid = 2; + + string username = 3; + + string peerhost = 4; + + uint32 sockport = 5; + + string proto_name = 6; + + string proto_ver = 7; + + uint32 keepalive = 8; +} + +message ClientInfo { + + string node = 1; + + string clientid = 2; + + string username = 3; + + string password = 4; + + string peerhost = 5; + + uint32 sockport = 6; + + string protocol = 7; + + string mountpoint = 8; + + bool is_superuser = 9; + + bool anonymous = 10; +} + +message Message { + + string node = 1; + + string id = 2; + + uint32 qos = 3; + + string from = 4; + + string topic = 5; + + bytes payload = 6; + + uint64 timestamp = 7; +} + +message Property { + + string name = 1; + + string value = 2; +} + +message TopicFilter { + + string name = 1; + + uint32 qos = 2; +} + +message SubOpts { + + // The QoS level + uint32 qos = 1; + + // The group name for shared subscription + string share = 2; + + // The Retain Handling option (MQTT v5.0) + // + // 0 = Send retained messages at the time of the subscribe + // 1 = Send retained messages at subscribe only if the subscription does + // not currently exist + // 2 = Do not send retained messages at the time of the subscribe + uint32 rh = 3; + + // The Retain as Published option (MQTT v5.0) + // + // If 1, Application Messages forwarded using this subscription keep the + // RETAIN flag they were published with. + // If 0, Application Messages forwarded using this subscription have the + // RETAIN flag set to 0. + // Retained messages sent when the subscription is established have the RETAIN flag set to 1. + uint32 rap = 4; + + // The No Local option (MQTT v5.0) + // + // If the value is 1, Application Messages MUST NOT be forwarded to a + // connection with a ClientID equal to the ClientID of the publishing + uint32 nl = 5; +} diff --git a/apps/emqx_exhook/rebar.config b/apps/emqx_exhook/rebar.config index 756c24c68..ebeaddeab 100644 --- a/apps/emqx_exhook/rebar.config +++ b/apps/emqx_exhook/rebar.config @@ -1,7 +1,21 @@ %%-*- mode: erlang -*- +{plugins, + [rebar3_proper, + {grpc_plugin, {git, "https://github.com/HJianBo/grpcbox_plugin", {tag, "v0.9.1"}}} +]}. -{deps, [{ecpool, {git, "https://github.com/emqx/ecpool", {tag, "v0.4.2"}}}, - {erlport, {git, "https://github.com/emqx/erlport", {tag, "v1.2.2"}}}]}. +{deps, + [{grpc, {git, "https://github.com/emqx/grpc", {tag, "0.5.0"}}} +]}. + +{grpc, + [{protos, ["priv/protos"]}, + {gpb_opts, [{module_name_prefix, "emqx_"}, + {module_name_suffix, "_pb"}]} +]}. + +{provider_hooks, + [{pre, [{compile, {grpc, gen}}]}]}. {edoc_opts, [{preprocess, true}]}. @@ -15,14 +29,19 @@ {xref_checks, [undefined_function_calls, undefined_functions, locals_not_used, deprecated_function_calls, warnings_as_errors, deprecated_functions]}. +{xref_ignores, [emqx_exhook_pb]}. + {cover_enabled, true}. {cover_opts, [verbose]}. {cover_export_enabled, true}. +{cover_excl_mods, [emqx_exhook_pb, + emqx_exhook_v_1_hook_provider_bhvr, + emqx_exhook_v_1_hook_provider_client]}. {profiles, - [{test, [ - {deps, [ {emqx_ct_helper, {git, "https://github.com/emqx/emqx-ct-helpers", {tag, "v1.2.2"}}} - , {cuttlefish, {git, "https://github.com/emqx/cuttlefish", {tag, "v3.0.0"}}} - ]} + [{test, + [{deps, + [{emqx_ct_helper, {git, "https://github.com/emqx/emqx-ct-helpers", {tag, "v1.3.1"}}} + ]} ]} ]}. diff --git a/apps/emqx_exhook/sdk/README.md b/apps/emqx_exhook/sdk/README.md deleted file mode 100644 index 435ca4d92..000000000 --- a/apps/emqx_exhook/sdk/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# SDKs - -A specific language SDK is a suite of codes for user-oriented friendly. - -Even it does not need it for you to develop the Multiple language support plugins, but it provides more friendly APIs and Abstract for you - - -Now, we provide the following SDKs: - -- Java: https://github.com/emqx/emqx-extension-java-sdk -- Python: https://github.com/emqx/emqx-extension-python-sdk - diff --git a/apps/emqx_exhook/src/emqx_exhook.app.src b/apps/emqx_exhook/src/emqx_exhook.app.src new file mode 100644 index 000000000..fd8bc98ae --- /dev/null +++ b/apps/emqx_exhook/src/emqx_exhook.app.src @@ -0,0 +1,12 @@ +{application, emqx_exhook, + [{description, "EMQ X Extension for Hook"}, + {vsn, "git"}, + {modules, []}, + {registered, []}, + {mod, {emqx_exhook_app, []}}, + {applications, [kernel,stdlib,grpc]}, + {env,[]}, + {licenses, ["Apache-2.0"]}, + {maintainers, ["EMQ X Team "]}, + {links, [{"Homepage", "https://emqx.io/"}]} + ]}. diff --git a/apps/emqx_exhook/src/emqx_exhook.app.src.script b/apps/emqx_exhook/src/emqx_exhook.app.src.script new file mode 100644 index 000000000..b549f9e39 --- /dev/null +++ b/apps/emqx_exhook/src/emqx_exhook.app.src.script @@ -0,0 +1,24 @@ +%%-*- mode: erlang -*- +%% .app.src.script + +RemoveLeadingV = + fun(Tag) -> + case re:run(Tag, "^[v]?[0-9]\.[0-9]\.([0-9]|(rc|beta|alpha)\.[0-9])", [{capture, none}]) of + nomatch -> + re:replace(Tag, "/", "-", [{return ,list}]); + _ -> + %% if it is a version number prefixed by 'v' or 'e', then remove it + re:replace(Tag, "[v]", "", [{return ,list}]) + end + end, + +case os:getenv("EMQX_DEPS_DEFAULT_VSN") of + false -> CONFIG; % env var not defined + [] -> CONFIG; % env var set to empty string + Tag -> + [begin + AppConf0 = lists:keystore(vsn, 1, AppConf, {vsn, RemoveLeadingV(Tag)}), + {application, App, AppConf0} + end || Conf = {application, App, AppConf} <- CONFIG] +end. + diff --git a/apps/emqx_exhook/src/emqx_exhook.appup.src b/apps/emqx_exhook/src/emqx_exhook.appup.src new file mode 100644 index 000000000..dcf0d8cdd --- /dev/null +++ b/apps/emqx_exhook/src/emqx_exhook.appup.src @@ -0,0 +1,9 @@ +%% -*-: erlang -*- +{VSN, + [ + {<<".*">>, []} + ], + [ + {<<".*">>, []} + ] +}. diff --git a/apps/emqx_exhook/src/emqx_extension_hook.erl b/apps/emqx_exhook/src/emqx_exhook.erl similarity index 55% rename from apps/emqx_exhook/src/emqx_extension_hook.erl rename to apps/emqx_exhook/src/emqx_exhook.erl index 295393c3b..c464f31b5 100644 --- a/apps/emqx_exhook/src/emqx_extension_hook.erl +++ b/apps/emqx_exhook/src/emqx_exhook.erl @@ -14,9 +14,9 @@ %% limitations under the License. %%-------------------------------------------------------------------- --module(emqx_extension_hook). +-module(emqx_exhook). --include("emqx_extension_hook.hrl"). +-include("emqx_exhook.hrl"). -include_lib("emqx/include/logger.hrl"). -logger_header("[ExHook]"). @@ -29,42 +29,42 @@ ]). -export([ cast/2 - , call_fold/4 + , call_fold/3 ]). %%-------------------------------------------------------------------- %% Mgmt APIs %%-------------------------------------------------------------------- --spec list() -> [emqx_extension_hook_driver:driver()]. +-spec list() -> [emqx_exhook_server:server()]. list() -> - [state(Name) || Name <- running()]. + [server(Name) || Name <- running()]. --spec enable(atom(), list()) -> ok | {error, term()}. +-spec enable(atom()|string(), list()) -> ok | {error, term()}. enable(Name, Opts) -> case lists:member(Name, running()) of true -> {error, already_started}; _ -> - case emqx_extension_hook_driver:load(Name, Opts) of - {ok, DriverState} -> - save(Name, DriverState); + case emqx_exhook_server:load(Name, Opts) of + {ok, ServiceState} -> + save(Name, ServiceState); {error, Reason} -> - ?LOG(error, "Load driver ~p failed: ~p", [Name, Reason]), + ?LOG(error, "Load server ~p failed: ~p", [Name, Reason]), {error, Reason} end end. --spec disable(atom()) -> ok | {error, term()}. +-spec disable(atom()|string()) -> ok | {error, term()}. disable(Name) -> - case state(Name) of + case server(Name) of undefined -> {error, not_running}; - Driver -> - ok = emqx_extension_hook_driver:unload(Driver), + Service -> + ok = emqx_exhook_server:unload(Service), unsave(Name) end. --spec disable_all() -> [atom()]. +-spec disable_all() -> [term()]. disable_all() -> [begin disable(Name), Name end || Name <- running()]. @@ -72,46 +72,44 @@ disable_all() -> %% Dispatch APIs %%---------------------------------------------------------- --spec cast(atom(), list()) -> ok. -cast(Name, Args) -> - cast(Name, Args, running()). +-spec cast(atom(), map()) -> ok. +cast(Hookpoint, Req) -> + cast(Hookpoint, Req, running()). cast(_, _, []) -> ok; -cast(Name, Args, [DriverName|More]) -> - emqx_extension_hook_driver:run_hook(Name, Args, state(DriverName)), - cast(Name, Args, More). +cast(Hookpoint, Req, [ServiceName|More]) -> + %% XXX: Need a real asynchronous running + _ = emqx_exhook_server:call(Hookpoint, Req, server(ServiceName)), + cast(Hookpoint, Req, More). --spec call_fold(atom(), list(), term(), function()) -> ok | {stop, term()}. -call_fold(Name, InfoArgs, AccArg, Validator) -> - call_fold(Name, InfoArgs, AccArg, Validator, running()). +-spec call_fold(atom(), term(), function()) + -> {ok, term()} + | {stop, term()}. +call_fold(Hookpoint, Req, AccFun) -> + call_fold(Hookpoint, Req, AccFun, running()). -call_fold(_, _, _, _, []) -> - ok; -call_fold(Name, InfoArgs, AccArg, Validator, [NameDriver|More]) -> - Driver = state(NameDriver), - case emqx_extension_hook_driver:run_hook_fold(Name, InfoArgs, AccArg, Driver) of - ok -> call_fold(Name, InfoArgs, AccArg, Validator, More); - {error, _} -> call_fold(Name, InfoArgs, AccArg, Validator, More); - {ok, NAcc} -> - case Validator(NAcc) of - true -> - {stop, NAcc}; - _ -> - ?LOG(error, "Got invalid return type for calling ~p on ~p", - [Name, emqx_extension_hook_driver:name(Driver)]), - call_fold(Name, InfoArgs, AccArg, Validator, More) - end +call_fold(_, Req, _, []) -> + {ok, Req}; +call_fold(Hookpoint, Req, AccFun, [ServiceName|More]) -> + case emqx_exhook_server:call(Hookpoint, Req, server(ServiceName)) of + {ok, Resp} -> + case AccFun(Req, Resp) of + {stop, NReq} -> {stop, NReq}; + {ok, NReq} -> call_fold(Hookpoint, NReq, AccFun, More) + end; + _ -> + call_fold(Hookpoint, Req, AccFun, More) end. %%---------------------------------------------------------- %% Storage -compile({inline, [save/2]}). -save(Name, DriverState) -> +save(Name, ServiceState) -> Saved = persistent_term:get(?APP, []), persistent_term:put(?APP, lists:reverse([Name | Saved])), - persistent_term:put({?APP, Name}, DriverState). + persistent_term:put({?APP, Name}, ServiceState). -compile({inline, [unsave/1]}). unsave(Name) -> @@ -128,9 +126,9 @@ unsave(Name) -> running() -> persistent_term:get(?APP, []). --compile({inline, [state/1]}). -state(Name) -> +-compile({inline, [server/1]}). +server(Name) -> case catch persistent_term:get({?APP, Name}) of {'EXIT', {badarg,_}} -> undefined; - State -> State + Service -> Service end. diff --git a/apps/emqx_exhook/src/emqx_extension_hook_app.erl b/apps/emqx_exhook/src/emqx_exhook_app.erl similarity index 80% rename from apps/emqx_exhook/src/emqx_extension_hook_app.erl rename to apps/emqx_exhook/src/emqx_exhook_app.erl index 990907913..1411b618d 100644 --- a/apps/emqx_exhook/src/emqx_extension_hook_app.erl +++ b/apps/emqx_exhook/src/emqx_exhook_app.erl @@ -14,40 +14,47 @@ %% limitations under the License. %%-------------------------------------------------------------------- --module(emqx_extension_hook_app). +-module(emqx_exhook_app). -behaviour(application). --include("emqx_extension_hook.hrl"). +-include("emqx_exhook.hrl"). --emqx_plugin(?MODULE). +-emqx_plugin(extension). -export([ start/2 , stop/1 , prep_stop/1 ]). +%% Internal export +-export([ load_server/2 + , unload_server/1 + , load_exhooks/0 + , unload_exhooks/0 + ]). + %%-------------------------------------------------------------------- %% Application callbacks %%-------------------------------------------------------------------- start(_StartType, _StartArgs) -> - {ok, Sup} = emqx_extension_hook_sup:start_link(), + {ok, Sup} = emqx_exhook_sup:start_link(), %% Load all dirvers - load_all_drivers(), + load_all_servers(), %% Register all hooks load_exhooks(), %% Register CLI - emqx_ctl:register_command(exhook, {emqx_extension_hook_cli, cli}, []), + emqx_ctl:register_command(exhook, {emqx_exhook_cli, cli}, []), {ok, Sup}. prep_stop(State) -> emqx_ctl:unregister_command(exhook), unload_exhooks(), - unload_all_drivers(), + unload_all_servers(), State. stop(_State) -> @@ -57,17 +64,19 @@ stop(_State) -> %% Internal funcs %%-------------------------------------------------------------------- -load_all_drivers() -> - load_all_drivers(application:get_env(?APP, drivers, [])). +load_all_servers() -> + lists:foreach(fun({Name, Options}) -> + load_server(Name, Options) + end, application:get_env(?APP, servers, [])). -load_all_drivers([]) -> - ok; -load_all_drivers([{Name, Opts}|Drivers]) -> - ok = emqx_extension_hook:enable(Name, Opts), - load_all_drivers(Drivers). +unload_all_servers() -> + emqx_exhook:disable_all(). -unload_all_drivers() -> - emqx_extension_hook:disable_all(). +load_server(Name, Options) -> + emqx_exhook:enable(Name, Options). + +unload_server(Name) -> + emqx_exhook:disable(Name). %%-------------------------------------------------------------------- %% Exhooks diff --git a/apps/emqx_exhook/src/emqx_extension_hook_cli.erl b/apps/emqx_exhook/src/emqx_exhook_cli.erl similarity index 64% rename from apps/emqx_exhook/src/emqx_extension_hook_cli.erl rename to apps/emqx_exhook/src/emqx_exhook_cli.erl index daca3412f..8bab9ced5 100644 --- a/apps/emqx_exhook/src/emqx_extension_hook_cli.erl +++ b/apps/emqx_exhook/src/emqx_exhook_cli.erl @@ -14,44 +14,44 @@ %% limitations under the License. %%-------------------------------------------------------------------- --module(emqx_extension_hook_cli). +-module(emqx_exhook_cli). --include("emqx_extension_hook.hrl"). +-include("emqx_exhook.hrl"). -export([cli/1]). -cli(["drivers", "list"]) -> +cli(["server", "list"]) -> if_enabled(fun() -> - Drivers = emqx_extension_hook:list(), - [emqx_ctl:print("Driver(~s)~n", [emqx_extension_hook_driver:format(Driver)]) || Driver <- Drivers] + Services = emqx_exhook:list(), + [emqx_ctl:print("HookServer(~s)~n", [emqx_exhook_server:format(Service)]) || Service <- Services] end); -cli(["drivers", "enable", Name0]) -> +cli(["server", "enable", Name0]) -> if_enabled(fun() -> Name = list_to_atom(Name0), - case proplists:get_value(Name, application:get_env(?APP, drivers, [])) of + case proplists:get_value(Name, application:get_env(?APP, servers, [])) of undefined -> emqx_ctl:print("not_found~n"); Opts -> - print(emqx_extension_hook:enable(Name, Opts)) + print(emqx_exhook:enable(Name, Opts)) end end); -cli(["drivers", "disable", Name]) -> +cli(["server", "disable", Name]) -> if_enabled(fun() -> - print(emqx_extension_hook:disable(list_to_atom(Name))) + print(emqx_exhook:disable(list_to_atom(Name))) end); -cli(["drivers", "stats"]) -> +cli(["server", "stats"]) -> if_enabled(fun() -> [emqx_ctl:print("~-35s:~w~n", [Name, N]) || {Name, N} <- stats()] end); cli(_) -> - emqx_ctl:usage([{"exhook drivers list", "List all running drivers"}, - {"exhook drivers enable ", "Enable a driver with configurations"}, - {"exhook drivers disable ", "Disable a driver"}, - {"exhook drivers stats", "Print drivers statistic"}]). + emqx_ctl:usage([{"exhook server list", "List all running exhook server"}, + {"exhook server enable ", "Enable a exhook server in the configuration"}, + {"exhook server disable ", "Disable a exhook server"}, + {"exhook server stats", "Print exhook server statistic"}]). print(ok) -> emqx_ctl:print("ok~n"); @@ -69,12 +69,12 @@ if_enabled(Fun) -> end. hint() -> - emqx_ctl:print("Please './bin/emqx_ctl plugins load emqx_extension_hook' first.~n"). + emqx_ctl:print("Please './bin/emqx_ctl plugins load emqx_exhook' first.~n"). stats() -> - lists:foldr(fun({K, N}, Acc) -> + lists:usort(lists:foldr(fun({K, N}, Acc) -> case atom_to_list(K) of "exhook." ++ Key -> [{Key, N}|Acc]; _ -> Acc end - end, [], emqx_metrics:all()). + end, [], emqx_metrics:all())). diff --git a/apps/emqx_exhook/src/emqx_exhook_handler.erl b/apps/emqx_exhook/src/emqx_exhook_handler.erl new file mode 100644 index 000000000..3a35073ca --- /dev/null +++ b/apps/emqx_exhook/src/emqx_exhook_handler.erl @@ -0,0 +1,288 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_exhook_handler). + +-include("emqx_exhook.hrl"). +-include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-logger_header("[ExHook]"). + +-export([ on_client_connect/2 + , on_client_connack/3 + , on_client_connected/2 + , on_client_disconnected/3 + , on_client_authenticate/2 + , on_client_check_acl/4 + , on_client_subscribe/3 + , on_client_unsubscribe/3 + ]). + +%% Session Lifecircle Hooks +-export([ on_session_created/2 + , on_session_subscribed/3 + , on_session_unsubscribed/3 + , on_session_resumed/2 + , on_session_discarded/2 + , on_session_takeovered/2 + , on_session_terminated/3 + ]). + +%% Utils +-export([ message/1 + , stringfy/1 + , merge_responsed_bool/2 + , merge_responsed_message/2 + , assign_to_message/2 + , clientinfo/1 + ]). + +-import(emqx_exhook, + [ cast/2 + , call_fold/3 + ]). + +-exhooks([ {'client.connect', {?MODULE, on_client_connect, []}} + , {'client.connack', {?MODULE, on_client_connack, []}} + , {'client.connected', {?MODULE, on_client_connected, []}} + , {'client.disconnected', {?MODULE, on_client_disconnected, []}} + , {'client.authenticate', {?MODULE, on_client_authenticate, []}} + , {'client.check_acl', {?MODULE, on_client_check_acl, []}} + , {'client.subscribe', {?MODULE, on_client_subscribe, []}} + , {'client.unsubscribe', {?MODULE, on_client_unsubscribe, []}} + , {'session.created', {?MODULE, on_session_created, []}} + , {'session.subscribed', {?MODULE, on_session_subscribed, []}} + , {'session.unsubscribed',{?MODULE, on_session_unsubscribed, []}} + , {'session.resumed', {?MODULE, on_session_resumed, []}} + , {'session.discarded', {?MODULE, on_session_discarded, []}} + , {'session.takeovered', {?MODULE, on_session_takeovered, []}} + , {'session.terminated', {?MODULE, on_session_terminated, []}} + ]). + +%%-------------------------------------------------------------------- +%% Clients +%%-------------------------------------------------------------------- + +on_client_connect(ConnInfo, Props) -> + Req = #{conninfo => conninfo(ConnInfo), + props => properties(Props) + }, + cast('client.connect', Req). + +on_client_connack(ConnInfo, Rc, Props) -> + Req = #{conninfo => conninfo(ConnInfo), + result_code => stringfy(Rc), + props => properties(Props)}, + cast('client.connack', Req). + +on_client_connected(ClientInfo, _ConnInfo) -> + Req = #{clientinfo => clientinfo(ClientInfo)}, + cast('client.connected', Req). + +on_client_disconnected(ClientInfo, Reason, _ConnInfo) -> + Req = #{clientinfo => clientinfo(ClientInfo), + reason => stringfy(Reason) + }, + cast('client.disconnected', Req). + +on_client_authenticate(ClientInfo, AuthResult) -> + Bool = maps:get(auth_result, AuthResult, undefined) == success, + Req = #{clientinfo => clientinfo(ClientInfo), + result => Bool + }, + + case call_fold('client.authenticate', Req, + fun merge_responsed_bool/2) of + {StopOrOk, #{result := Bool}} when is_boolean(Bool) -> + Result = case Bool of true -> success; _ -> not_authorized end, + {StopOrOk, AuthResult#{auth_result => Result, anonymous => false}}; + _ -> + {ok, AuthResult} + end. + +on_client_check_acl(ClientInfo, PubSub, Topic, Result) -> + Bool = Result == allow, + Type = case PubSub of + publish -> 'PUBLISH'; + subscribe -> 'SUBSCRIBE' + end, + Req = #{clientinfo => clientinfo(ClientInfo), + type => Type, + topic => Topic, + result => Bool + }, + case call_fold('client.check_acl', Req, + fun merge_responsed_bool/2) of + {StopOrOk, #{result := Bool}} when is_boolean(Bool) -> + NResult = case Bool of true -> allow; _ -> deny end, + {StopOrOk, NResult}; + _ -> {ok, Result} + end. + +on_client_subscribe(ClientInfo, Props, TopicFilters) -> + Req = #{clientinfo => clientinfo(ClientInfo), + props => properties(Props), + topic_filters => topicfilters(TopicFilters) + }, + cast('client.subscribe', Req). + +on_client_unsubscribe(ClientInfo, Props, TopicFilters) -> + Req = #{clientinfo => clientinfo(ClientInfo), + props => properties(Props), + topic_filters => topicfilters(TopicFilters) + }, + cast('client.unsubscribe', Req). + +%%-------------------------------------------------------------------- +%% Session +%%-------------------------------------------------------------------- + +on_session_created(ClientInfo, _SessInfo) -> + Req = #{clientinfo => clientinfo(ClientInfo)}, + cast('session.created', Req). + +on_session_subscribed(ClientInfo, Topic, SubOpts) -> + Req = #{clientinfo => clientinfo(ClientInfo), + topic => Topic, + subopts => maps:with([qos, share, rh, rap, nl], SubOpts) + }, + cast('session.subscribed', Req). + +on_session_unsubscribed(ClientInfo, Topic, _SubOpts) -> + Req = #{clientinfo => clientinfo(ClientInfo), + topic => Topic + }, + cast('session.unsubscribed', Req). + +on_session_resumed(ClientInfo, _SessInfo) -> + Req = #{clientinfo => clientinfo(ClientInfo)}, + cast('session.resumed', Req). + +on_session_discarded(ClientInfo, _SessInfo) -> + Req = #{clientinfo => clientinfo(ClientInfo)}, + cast('session.discarded', Req). + +on_session_takeovered(ClientInfo, _SessInfo) -> + Req = #{clientinfo => clientinfo(ClientInfo)}, + cast('session.takeovered', Req). + +on_session_terminated(ClientInfo, Reason, _SessInfo) -> + Req = #{clientinfo => clientinfo(ClientInfo), + reason => stringfy(Reason)}, + cast('session.terminated', Req). + +%%-------------------------------------------------------------------- +%% Types + +properties(undefined) -> []; +properties(M) when is_map(M) -> + maps:fold(fun(K, V, Acc) -> + [#{name => stringfy(K), + value => stringfy(V)} | Acc] + end, [], M). + +conninfo(_ConnInfo = + #{clientid := ClientId, username := Username, peername := {Peerhost, _}, + sockname := {_, SockPort}, proto_name := ProtoName, proto_ver := ProtoVer, + keepalive := Keepalive}) -> + #{node => stringfy(node()), + clientid => ClientId, + username => maybe(Username), + peerhost => ntoa(Peerhost), + sockport => SockPort, + proto_name => ProtoName, + proto_ver => stringfy(ProtoVer), + keepalive => Keepalive}. + +clientinfo(ClientInfo = + #{clientid := ClientId, username := Username, peerhost := PeerHost, + sockport := SockPort, protocol := Protocol, mountpoint := Mountpoiont}) -> + #{node => stringfy(node()), + clientid => ClientId, + username => maybe(Username), + password => maybe(maps:get(password, ClientInfo, undefined)), + peerhost => ntoa(PeerHost), + sockport => SockPort, + protocol => stringfy(Protocol), + mountpoint => maybe(Mountpoiont), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true)}. + +message(#message{id = Id, qos = Qos, from = From, topic = Topic, payload = Payload, timestamp = Ts}) -> + #{node => stringfy(node()), + id => hexstr(Id), + qos => Qos, + from => stringfy(From), + topic => Topic, + payload => Payload, + timestamp => Ts}. + +assign_to_message(#{qos := Qos, topic := Topic, payload := Payload}, Message) -> + Message#message{qos = Qos, topic = Topic, payload = Payload}. + +topicfilters(Tfs) when is_list(Tfs) -> + [#{name => Topic, qos => Qos} || {Topic, #{qos := Qos}} <- Tfs]. + +ntoa({0,0,0,0,0,16#ffff,AB,CD}) -> + list_to_binary(inet_parse:ntoa({AB bsr 8, AB rem 256, CD bsr 8, CD rem 256})); +ntoa(IP) -> + list_to_binary(inet_parse:ntoa(IP)). + +maybe(undefined) -> <<>>; +maybe(B) -> B. + +%% @private +stringfy(Term) when is_binary(Term) -> + Term; +stringfy(Term) when is_integer(Term) -> + integer_to_binary(Term); +stringfy(Term) when is_atom(Term) -> + atom_to_binary(Term, utf8); +stringfy(Term) -> + unicode:characters_to_binary((io_lib:format("~0p", [Term]))). + +hexstr(B) -> + iolist_to_binary([io_lib:format("~2.16.0B", [X]) || X <- binary_to_list(B)]). + +%%-------------------------------------------------------------------- +%% Acc funcs + +%% see exhook.proto +merge_responsed_bool(Req, #{type := 'IGNORE'}) -> + {ok, Req}; +merge_responsed_bool(Req, #{type := Type, value := {bool_result, NewBool}}) + when is_boolean(NewBool) -> + NReq = Req#{result => NewBool}, + case Type of + 'CONTINUE' -> {ok, NReq}; + 'STOP_AND_RETURN' -> {stop, NReq} + end; +merge_responsed_bool(Req, Resp) -> + ?LOG(warning, "Unknown responsed value ~0p to merge to callback chain", [Resp]), + {ok, Req}. + +merge_responsed_message(Req, #{type := 'IGNORE'}) -> + {ok, Req}; +merge_responsed_message(Req, #{type := Type, value := {message, NMessage}}) -> + NReq = Req#{message => NMessage}, + case Type of + 'CONTINUE' -> {ok, NReq}; + 'STOP_AND_RETURN' -> {stop, NReq} + end; +merge_responsed_message(Req, Resp) -> + ?LOG(warning, "Unknown responsed value ~0p to merge to callback chain", [Resp]), + {ok, Req}. diff --git a/apps/emqx_exhook/src/emqx_exhook_server.erl b/apps/emqx_exhook/src/emqx_exhook_server.erl new file mode 100644 index 000000000..451983437 --- /dev/null +++ b/apps/emqx_exhook/src/emqx_exhook_server.erl @@ -0,0 +1,286 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_exhook_server). + +-include_lib("emqx/include/logger.hrl"). + +-logger_header("[ExHook Svr]"). + +-define(PB_CLIENT_MOD, emqx_exhook_v_1_hook_provider_client). + +%% Load/Unload +-export([ load/2 + , unload/1 + ]). + +%% APIs +-export([call/3]). + +%% Infos +-export([ name/1 + , format/1 + ]). + +-record(server, { + %% Server name (equal to grpc client channel name) + name :: server_name(), + %% The server started options + options :: list(), + %% gRPC channel pid + channel :: pid(), + %% Registered hook names and options + hookspec :: #{hookpoint() => map()}, + %% Metrcis name prefix + prefix :: list() + }). + +-type server_name() :: string(). +-type server() :: #server{}. + +-type hookpoint() :: 'client.connect' + | 'client.connack' + | 'client.connected' + | 'client.disconnected' + | 'client.authenticate' + | 'client.check_acl' + | 'client.subscribe' + | 'client.unsubscribe' + | 'session.created' + | 'session.subscribed' + | 'session.unsubscribed' + | 'session.resumed' + | 'session.discarded' + | 'session.takeovered' + | 'session.terminated' + | 'message.publish' + | 'message.delivered' + | 'message.acked' + | 'message.dropped'. + +-export_type([server/0]). + +-dialyzer({nowarn_function, [inc_metrics/2]}). + +%%-------------------------------------------------------------------- +%% Load/Unload APIs +%%-------------------------------------------------------------------- + +-spec load(atom(), list()) -> {ok, server()} | {error, term()} . +load(Name0, Opts0) -> + Name = prefix(Name0), + {SvrAddr, ClientOpts} = channel_opts(Opts0), + case emqx_exhook_sup:start_grpc_client_channel(Name, SvrAddr, ClientOpts) of + {ok, _ChannPoolPid} -> + case do_init(Name) of + {ok, HookSpecs} -> + %% Reigster metrics + Prefix = lists:flatten(io_lib:format("exhook.~s.", [Name])), + ensure_metrics(Prefix, HookSpecs), + {ok, #server{name = Name, + options = Opts0, + channel = _ChannPoolPid, + hookspec = HookSpecs, + prefix = Prefix }}; + {error, _} = E -> + emqx_exhook_sup:stop_grpc_client_channel(Name), E + end; + {error, _} = E -> E + end. + +%% @private +prefix(Name) when is_atom(Name) -> + "exhook:" ++ atom_to_list(Name); +prefix(Name) when is_binary(Name) -> + "exhook:" ++ binary_to_list(Name); +prefix(Name) when is_list(Name) -> + "exhook:" ++ Name. + +%% @private +channel_opts(Opts) -> + Scheme = proplists:get_value(scheme, Opts), + Host = proplists:get_value(host, Opts), + Port = proplists:get_value(port, Opts), + SvrAddr = lists:flatten(io_lib:format("~s://~s:~w", [Scheme, Host, Port])), + ClientOpts = case Scheme of + https -> + SslOpts = lists:keydelete(ssl, 1, proplists:get_value(ssl_options, Opts, [])), + #{gun_opts => + #{transport => ssl, + transport_opts => SslOpts}}; + _ -> #{} + end, + {SvrAddr, ClientOpts}. + +-spec unload(server()) -> ok. +unload(#server{name = Name}) -> + _ = do_deinit(Name), + _ = emqx_exhook_sup:stop_grpc_client_channel(Name), + ok. + +do_deinit(Name) -> + _ = do_call(Name, 'on_provider_unloaded', #{}), + ok. + +do_init(ChannName) -> + Req = #{broker => maps:from_list(emqx_sys:info())}, + case do_call(ChannName, 'on_provider_loaded', Req) of + {ok, InitialResp} -> + try + {ok, resovle_hookspec(maps:get(hooks, InitialResp, []))} + catch _:Reason:Stk -> + ?LOG(error, "try to init ~p failed, reason: ~p, stacktrace: ~0p", + [ChannName, Reason, Stk]), + {error, Reason} + end; + {error, Reason} -> + {error, Reason} + end. + +%% @private +resovle_hookspec(HookSpecs) when is_list(HookSpecs) -> + MessageHooks = message_hooks(), + AvailableHooks = available_hooks(), + lists:foldr(fun(HookSpec, Acc) -> + case maps:get(name, HookSpec, undefined) of + undefined -> Acc; + Name0 -> + Name = try binary_to_existing_atom(Name0, utf8) catch T:R:_ -> {T,R} end, + case lists:member(Name, AvailableHooks) of + true -> + case lists:member(Name, MessageHooks) of + true -> + Acc#{Name => #{topics => maps:get(topics, HookSpec, [])}}; + _ -> + Acc#{Name => #{}} + end; + _ -> error({unknown_hookpoint, Name}) + end + end + end, #{}, HookSpecs). + +ensure_metrics(Prefix, HookSpecs) -> + Keys = [list_to_atom(Prefix ++ atom_to_list(Hookpoint)) + || Hookpoint <- maps:keys(HookSpecs)], + lists:foreach(fun emqx_metrics:ensure/1, Keys). + +format(#server{name = Name, hookspec = Hooks}) -> + io_lib:format("name=~p, hooks=~0p", [Name, Hooks]). + +%%-------------------------------------------------------------------- +%% APIs +%%-------------------------------------------------------------------- + +name(#server{name = Name}) -> + Name. + +-spec call(hookpoint(), map(), server()) + -> ignore + | {ok, Resp :: term()} + | {error, term()}. +call(Hookpoint, Req, #server{name = ChannName, hookspec = Hooks, prefix = Prefix}) -> + GrpcFunc = hk2func(Hookpoint), + case maps:get(Hookpoint, Hooks, undefined) of + undefined -> ignore; + Opts -> + NeedCall = case lists:member(Hookpoint, message_hooks()) of + false -> true; + _ -> + #{message := #{topic := Topic}} = Req, + match_topic_filter(Topic, maps:get(topics, Opts, [])) + end, + case NeedCall of + false -> ignore; + _ -> + inc_metrics(Prefix, Hookpoint), + do_call(ChannName, GrpcFunc, Req) + end + end. + +%% @private +inc_metrics(IncFun, Name) when is_function(IncFun) -> + %% BACKW: e4.2.0-e4.2.2 + {env, [Prefix|_]} = erlang:fun_info(IncFun, env), + inc_metrics(Prefix, Name); +inc_metrics(Prefix, Name) when is_list(Prefix) -> + emqx_metrics:inc(list_to_atom(Prefix ++ atom_to_list(Name))). + +-compile({inline, [match_topic_filter/2]}). +match_topic_filter(_, []) -> + true; +match_topic_filter(TopicName, TopicFilter) -> + lists:any(fun(F) -> emqx_topic:match(TopicName, F) end, TopicFilter). + +-spec do_call(string(), atom(), map()) -> {ok, map()} | {error, term()}. +do_call(ChannName, Fun, Req) -> + Options = #{channel => ChannName}, + ?LOG(debug, "Call ~0p:~0p(~0p, ~0p)", [?PB_CLIENT_MOD, Fun, Req, Options]), + case catch apply(?PB_CLIENT_MOD, Fun, [Req, Options]) of + {ok, Resp, _Metadata} -> + ?LOG(debug, "Response {ok, ~0p, ~0p}", [Resp, _Metadata]), + {ok, Resp}; + {error, {Code, Msg}, _Metadata} -> + ?LOG(error, "CALL ~0p:~0p(~0p, ~0p) response errcode: ~0p, errmsg: ~0p", + [?PB_CLIENT_MOD, Fun, Req, Options, Code, Msg]), + {error, {Code, Msg}}; + {error, Reason} -> + ?LOG(error, "CALL ~0p:~0p(~0p, ~0p) error: ~0p", + [?PB_CLIENT_MOD, Fun, Req, Options, Reason]), + {error, Reason}; + {'EXIT', {Reason, Stk}} -> + ?LOG(error, "CALL ~0p:~0p(~0p, ~0p) throw an exception: ~0p, stacktrace: ~0p", + [?PB_CLIENT_MOD, Fun, Req, Options, Reason, Stk]), + {error, Reason} + end. + +%%-------------------------------------------------------------------- +%% Internal funcs +%%-------------------------------------------------------------------- + +-compile({inline, [hk2func/1]}). +hk2func('client.connect') -> 'on_client_connect'; +hk2func('client.connack') -> 'on_client_connack'; +hk2func('client.connected') -> 'on_client_connected'; +hk2func('client.disconnected') -> 'on_client_disconnected'; +hk2func('client.authenticate') -> 'on_client_authenticate'; +hk2func('client.check_acl') -> 'on_client_check_acl'; +hk2func('client.subscribe') -> 'on_client_subscribe'; +hk2func('client.unsubscribe') -> 'on_client_unsubscribe'; +hk2func('session.created') -> 'on_session_created'; +hk2func('session.subscribed') -> 'on_session_subscribed'; +hk2func('session.unsubscribed') -> 'on_session_unsubscribed'; +hk2func('session.resumed') -> 'on_session_resumed'; +hk2func('session.discarded') -> 'on_session_discarded'; +hk2func('session.takeovered') -> 'on_session_takeovered'; +hk2func('session.terminated') -> 'on_session_terminated'; +hk2func('message.publish') -> 'on_message_publish'; +hk2func('message.delivered') ->'on_message_delivered'; +hk2func('message.acked') -> 'on_message_acked'; +hk2func('message.dropped') ->'on_message_dropped'. + +-compile({inline, [message_hooks/0]}). +message_hooks() -> + ['message.publish', 'message.delivered', + 'message.acked', 'message.dropped']. + +-compile({inline, [available_hooks/0]}). +available_hooks() -> + ['client.connect', 'client.connack', 'client.connected', + 'client.disconnected', 'client.authenticate', 'client.check_acl', + 'client.subscribe', 'client.unsubscribe', + 'session.created', 'session.subscribed', 'session.unsubscribed', + 'session.resumed', 'session.discarded', 'session.takeovered', + 'session.terminated' | message_hooks()]. diff --git a/apps/emqx_exhook/src/emqx_extension_hook_sup.erl b/apps/emqx_exhook/src/emqx_exhook_sup.erl similarity index 67% rename from apps/emqx_exhook/src/emqx_extension_hook_sup.erl rename to apps/emqx_exhook/src/emqx_exhook_sup.erl index 64b94070f..c8d2ecf35 100644 --- a/apps/emqx_exhook/src/emqx_extension_hook_sup.erl +++ b/apps/emqx_exhook/src/emqx_exhook_sup.erl @@ -14,7 +14,7 @@ %% limitations under the License. %%-------------------------------------------------------------------- --module(emqx_extension_hook_sup). +-module(emqx_exhook_sup). -behaviour(supervisor). @@ -22,8 +22,8 @@ , init/1 ]). --export([ start_driver_pool/1 - , stop_driver_pool/1 +-export([ start_grpc_client_channel/3 + , stop_grpc_client_channel/1 ]). %%-------------------------------------------------------------------- @@ -40,11 +40,20 @@ init([]) -> %% APIs %%-------------------------------------------------------------------- --spec start_driver_pool(map()) -> {ok, pid()} | {error, term()}. -start_driver_pool(Spec) -> - supervisor:start_child(?MODULE, Spec). +-spec start_grpc_client_channel( + string(), + uri_string:uri_string(), + grpc_client:options()) -> {ok, pid()} | {error, term()}. +start_grpc_client_channel(Name, SvrAddr, Options) -> + grpc_client_sup:create_channel_pool(Name, SvrAddr, Options). --spec stop_driver_pool(atom()) -> ok. -stop_driver_pool(Name) -> - ok = supervisor:terminate_child(?MODULE, Name), - ok = supervisor:delete_child(?MODULE, Name). +-spec stop_grpc_client_channel(string()) -> ok. +stop_grpc_client_channel(Name) -> + %% Avoid crash due to hot-upgrade had unloaded + %% grpc application + try + grpc_client_sup:stop_channel_pool(Name) + catch + _:_:_ -> + ok + end. diff --git a/apps/emqx_exhook/src/emqx_extension_hook.app.src b/apps/emqx_exhook/src/emqx_extension_hook.app.src deleted file mode 100644 index 0575ec95f..000000000 --- a/apps/emqx_exhook/src/emqx_extension_hook.app.src +++ /dev/null @@ -1,14 +0,0 @@ -{application, emqx_extension_hook, - [{description, "EMQ X Extension for Hook"}, - {vsn, "git"}, - {modules, []}, - {registered, []}, - {mod, {emqx_extension_hook_app, []}}, - {applications, [kernel, stdlib, ecpool, erlport]}, - {env,[]}, - {licenses, ["Apache-2.0"]}, - {maintainers, ["EMQ X Team "]}, - {links, [{"Homepage", "https://emqx.io/"}, - {"Github", "https://github.com/emqx/emqx-extension-hook"} - ]} - ]}. diff --git a/apps/emqx_exhook/src/emqx_extension_hook_driver.erl b/apps/emqx_exhook/src/emqx_extension_hook_driver.erl deleted file mode 100644 index 2600c6e6b..000000000 --- a/apps/emqx_exhook/src/emqx_extension_hook_driver.erl +++ /dev/null @@ -1,305 +0,0 @@ -%%-------------------------------------------------------------------- -%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. -%% -%% Licensed under the Apache License, Version 2.0 (the "License"); -%% you may not use this file except in compliance with the License. -%% You may obtain a copy of the License at -%% -%% http://www.apache.org/licenses/LICENSE-2.0 -%% -%% Unless required by applicable law or agreed to in writing, software -%% distributed under the License is distributed on an "AS IS" BASIS, -%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -%% See the License for the specific language governing permissions and -%% limitations under the License. -%%-------------------------------------------------------------------- - --module(emqx_extension_hook_driver). - --include_lib("emqx/include/logger.hrl"). - --logger_header("[ExHook Driver]"). - -%% Load/Unload --export([ load/2 - , unload/1 - , connect/1 - ]). - -%% APIs --export([ run_hook/3 - , run_hook_fold/4]). - -%% Infos --export([ name/1 - , format/1 - ]). - --record(driver, { - %% Driver name (equal to ecpool name) - name :: driver_name(), - %% Driver type - type :: driver_type(), - %% Initial Module name - init :: atom(), - %% Hook Spec - hookspec :: hook_spec(), - %% Metric fun - incfun :: function(), - %% low layer state - state - }). - --type driver_name() :: python | python3 | java | webhook | lua | atom(). --type driver_type() :: python | webhok | java | atom(). --type driver() :: #driver{}. - --type hook_spec() :: #{hookname() => [{callback_m(), callback_f(), spec()}]}. --type hookname() :: client_connect - | client_connack - | client_connected - | client_disconnected - | client_authenticate - | client_check_acl - | client_subscribe - | client_unsubscribe - | session_created - | session_subscribed - | session_unsubscribed - | session_resumed - | session_discarded - | session_takeovered - | session_terminated - | message_publish - | message_delivered - | message_acked - | message_dropped. - --type callback_m() :: atom(). - --type callback_f() :: atom(). - --type spec() :: #{ - topic => binary() %% for `message` hook only - }. - --export_type([driver/0]). - -%%-------------------------------------------------------------------- -%% Load/Unload APIs -%%-------------------------------------------------------------------- - --spec load(atom(), list()) -> {ok, driver()} | {error, term()} . -load(Name, Opts0) -> - case lists:keytake(init_module, 1, Opts0) of - false -> {error, not_found_initial_module}; - {value, {_,InitM}, Opts} -> - Spec = pool_spec(Name, Opts), - {ok, _} = emqx_extension_hook_sup:start_driver_pool(Spec), - do_init(Name, InitM) - end. - --spec unload(driver()) -> ok. -unload(#driver{name = Name, init = InitM}) -> - do_deinit(Name, InitM), - emqx_extension_hook_sup:stop_driver_pool(Name). - -do_deinit(Name, InitM) -> - _ = raw_call(type(Name), Name, InitM, 'deinit', []), - ok. - -do_init(Name, InitM) -> - Type = type(Name), - case raw_call(Type, Name, InitM, 'init', []) of - {ok, {HookSpec, State}} -> - NHookSpec = resovle_hook_spec(HookSpec), - %% Reigster metrics - Prefix = "exhook." ++ atom_to_list(Name) ++ ".", - ensure_metrics(Prefix, NHookSpec), - {ok, #driver{type = Type, - name = Name, - init = InitM, - state = State, - hookspec = NHookSpec, - incfun = incfun(Prefix) }}; - {error, Reason} -> - emqx_extension_hook_sup:stop_driver_pool(Name), - {error, Reason} - end. - -%% @private -pool_spec(Name, Opts) -> - NOpts = lists:keystore(pool_size, 1, Opts, {pool_size, 1}), - ecpool:pool_spec(Name, Name, ?MODULE, [{name, Name} | NOpts]). - -resovle_hook_spec(HookSpec) -> - Atom = fun(B) -> list_to_atom(B) end, - HookSpec1 = lists:map(fun({Name, Module, Func}) -> - {Name, Module, Func, []}; - (Other) -> Other - end, HookSpec), - lists:foldr( - fun({Name, Module, Func, Spec}, Acc) -> - NameAtom = Atom(Name), - Acc#{NameAtom => [{Atom(Module), Atom(Func), maps:from_list(Spec)} | maps:get(NameAtom, Acc, [])]} - end, #{}, HookSpec1). - -ensure_metrics(Prefix, HookSpec) -> - Keys = [ list_to_atom(Prefix ++ atom_to_list(K)) || K <- maps:keys(HookSpec)], - lists:foreach(fun emqx_metrics:ensure/1, Keys). - -incfun(Prefix) -> - fun(Name) -> - emqx_metrics:inc(list_to_atom(Prefix ++ atom_to_list(Name))) - end. - -format(#driver{name = Name, init = InitM, hookspec = Hooks}) -> - io_lib:format("name=~p, init_module=~p, hooks=~0p", [Name, InitM, maps:keys(Hooks)]). - -%%-------------------------------------------------------------------- -%% ecpool callback -%%-------------------------------------------------------------------- - --spec connect(list()) -> {ok, pid()} | {error, any()}. -connect(Opts0) -> - case lists:keytake(name, 1, lists:keydelete(ecpool_worker_id, 1, Opts0)) of - {_,{_, Name}, Opts} - when Name =:= python; - Name =:= python3 -> - NOpts = resovle_search_path(python, Opts), - python:start_link([{python, atom_to_list(Name)} | NOpts]); - {_,{_, Name}, Opts} - when Name =:= java -> - NOpts = resovle_search_path(java, Opts), - java:start_link([{java, atom_to_list(Name)} | NOpts]) - end. - -%% @private -resovle_search_path(java, Opts) -> - case proplists:get_value(java_path, Opts) of - undefined -> Opts; - Path -> - Solved = lists:flatten( - lists:join(pathsep(), - [expand_jar_packages(filename:absname(P)) - || P <- re:split(Path, pathsep(), [{return, list}]), P /= ""])), - lists:keystore(java_path, 1, Opts, {java_path, Solved}) - end; - -resovle_search_path(_, Opts) -> - Opts. - -expand_jar_packages(Path) -> - IsJarPkgs = fun(Name) -> - Ext = filename:extension(Name), - Ext == ".jar" orelse Ext == ".zip" - end, - case file:list_dir(Path) of - {ok, []} -> [Path]; - {error, _} -> [Path]; - {ok, Names} -> - lists:join(pathsep(), - [Path] ++ [filename:join([Path, Name]) || Name <- Names, IsJarPkgs(Name)]) - end. - -pathsep() -> - case os:type() of - {win32, _} -> - ";"; - _ -> - ":" - end. - -%%-------------------------------------------------------------------- -%% APIs -%%-------------------------------------------------------------------- - -name(#driver{name = Name}) -> - Name. - --spec run_hook(atom(), list(), driver()) - -> ok - | {ok, term()} - | {error, term()}. -run_hook(Name, Args, Driver = #driver{hookspec = HookSpec, incfun = IncFun}) -> - case maps:get(Name, HookSpec, []) of - [] -> ok; - Cbs -> - lists:foldl(fun({M, F, Opts}, _) -> - case match_topic_filter(Name, proplists:get_value(topic, Args, null), maps:get(topics, Opts, [])) of - true -> - IncFun(Name), - call(M, F, Args, Driver); - _ -> ok - end - end, ok, Cbs) - end. - --spec run_hook_fold(atom(), list(), any(), driver()) - -> ok - | {ok, term()} - | {error, term()}. -run_hook_fold(Name, Args, Acc0, Driver = #driver{hookspec = HookSpec, incfun = IncFun}) -> - case maps:get(Name, HookSpec, []) of - [] -> ok; - Cbs -> - lists:foldl(fun({M, F, Opts}, Acc) -> - case match_topic_filter(Name, proplists:get_value(topic, Args, null), maps:get(topics, Opts, [])) of - true -> - IncFun(Name), - call(M, F, Args ++ [Acc], Driver); - _ -> ok - end - end, Acc0, Cbs) - end. - --compile({inline, [match_topic_filter/3]}). -match_topic_filter(_Name, null, _TopicFilter) -> - true; -match_topic_filter(Name, TopicName, TopicFilter) - when Name =:= message_publish; - Name =:= message_delivered; - Name =:= message_dropped; - Name =:= message_acked -> - lists:any(fun(F) -> emqx_topic:match(TopicName, F) end, TopicFilter); -match_topic_filter(_, _, _) -> - true. - --spec call(atom(), atom(), list(), driver()) -> ok | {ok, term()} | {error, term()}. -call(Mod, Fun, Args, #driver{name = Name, type = Type, state = State}) -> - with_pool(Name, fun(C) -> - do_call(Type, C, Mod, Fun, Args ++ [State]) - end). - -raw_call(Type, Name, Mod, Fun, Args) when is_list(Args) -> - with_pool(Name, fun(C) -> - do_call(Type, C, Mod, Fun, Args) - end). - -do_call(Type, C, M, F, A) -> - case catch apply(Type, call, [C, M, F, A]) of - ok -> ok; - undefined -> ok; - {_Ok = 0, Return} -> {ok, Return}; - {_Err = 1, Reason} -> {error, Reason}; - {'EXIT', Reason, Stk} -> - ?LOG(error, "CALL ~p ~p:~p(~p), exception: ~p, stacktrace ~0p", - [Type, M, F, A, Reason, Stk]), - {error, Reason}; - _X -> - ?LOG(error, "CALL ~p ~p:~p(~p), unknown return: ~0p", - [Type, M, F, A, _X]), - {error, unknown_return_format} - end. - -%%-------------------------------------------------------------------- -%% Internal funcs -%%-------------------------------------------------------------------- - -with_pool(Name, Fun) -> - ecpool:with_client(Name, Fun). - -type(python3) -> python; -type(python) -> python; -type(Name) -> Name. - diff --git a/apps/emqx_exhook/src/emqx_extension_hook_handler.erl b/apps/emqx_exhook/src/emqx_extension_hook_handler.erl deleted file mode 100644 index 3ce9e4e90..000000000 --- a/apps/emqx_exhook/src/emqx_extension_hook_handler.erl +++ /dev/null @@ -1,249 +0,0 @@ -%%-------------------------------------------------------------------- -%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. -%% -%% Licensed under the Apache License, Version 2.0 (the "License"); -%% you may not use this file except in compliance with the License. -%% You may obtain a copy of the License at -%% -%% http://www.apache.org/licenses/LICENSE-2.0 -%% -%% Unless required by applicable law or agreed to in writing, software -%% distributed under the License is distributed on an "AS IS" BASIS, -%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -%% See the License for the specific language governing permissions and -%% limitations under the License. -%%-------------------------------------------------------------------- - --module(emqx_extension_hook_handler). - --include("emqx_extension_hook.hrl"). --include_lib("emqx/include/emqx.hrl"). --include_lib("emqx/include/logger.hrl"). - --logger_header("[ExHook]"). - --export([ on_client_connect/2 - , on_client_connack/3 - , on_client_connected/2 - , on_client_disconnected/3 - , on_client_authenticate/2 - , on_client_check_acl/4 - , on_client_subscribe/3 - , on_client_unsubscribe/3 - ]). - -%% Session Lifecircle Hooks --export([ on_session_created/2 - , on_session_subscribed/3 - , on_session_unsubscribed/3 - , on_session_resumed/2 - , on_session_discarded/2 - , on_session_takeovered/2 - , on_session_terminated/3 - ]). - -%% Utils --export([ message/1 - , validator/1 - , assign_to_message/2 - , clientinfo/1 - , stringfy/1 - ]). - --import(emqx_extension_hook, - [ cast/2 - , call_fold/4 - ]). - --exhooks([ {'client.connect', {?MODULE, on_client_connect, []}} - , {'client.connack', {?MODULE, on_client_connack, []}} - , {'client.connected', {?MODULE, on_client_connected, []}} - , {'client.disconnected', {?MODULE, on_client_disconnected, []}} - , {'client.authenticate', {?MODULE, on_client_authenticate, []}} - , {'client.check_acl', {?MODULE, on_client_check_acl, []}} - , {'client.subscribe', {?MODULE, on_client_subscribe, []}} - , {'client.unsubscribe', {?MODULE, on_client_unsubscribe, []}} - , {'session.created', {?MODULE, on_session_created, []}} - , {'session.subscribed', {?MODULE, on_session_subscribed, []}} - , {'session.unsubscribed',{?MODULE, on_session_unsubscribed, []}} - , {'session.resumed', {?MODULE, on_session_resumed, []}} - , {'session.discarded', {?MODULE, on_session_discarded, []}} - , {'session.takeovered', {?MODULE, on_session_takeovered, []}} - , {'session.terminated', {?MODULE, on_session_terminated, []}} - ]). - -%%-------------------------------------------------------------------- -%% Clients -%%-------------------------------------------------------------------- - -on_client_connect(ConnInfo, _Props) -> - cast('client_connect', [conninfo(ConnInfo), props(_Props)]). - -on_client_connack(ConnInfo, Rc, _Props) -> - cast('client_connack', [conninfo(ConnInfo), Rc, props(_Props)]). - -on_client_connected(ClientInfo, _ConnInfo) -> - cast('client_connected', [clientinfo(ClientInfo)]). - -on_client_disconnected(ClientInfo, {shutdown, Reason}, ConnInfo) when is_atom(Reason) -> - on_client_disconnected(ClientInfo, Reason, ConnInfo); -on_client_disconnected(ClientInfo, Reason, _ConnInfo) -> - cast('client_disconnected', [clientinfo(ClientInfo), stringfy(Reason)]). - -on_client_authenticate(ClientInfo, AuthResult) -> - AccArg = maps:get(auth_result, AuthResult, undefined) == success, - Name = 'client_authenticate', - case call_fold(Name, [clientinfo(ClientInfo)], AccArg, validator(Name)) of - {stop, Bool} when is_boolean(Bool) -> - Result = case Bool of true -> success; _ -> not_authorized end, - {stop, AuthResult#{auth_result => Result, anonymous => false}}; - _ -> - {ok, AuthResult} - end. - -on_client_check_acl(ClientInfo, PubSub, Topic, Result) -> - AccArg = Result == allow, - Name = 'client_check_acl', - case call_fold(Name, [clientinfo(ClientInfo), PubSub, Topic], AccArg, validator(Name)) of - {stop, Bool} when is_boolean(Bool) -> - NResult = case Bool of true -> allow; _ -> deny end, - {stop, NResult}; - _ -> {ok, Result} - end. - -on_client_subscribe(ClientInfo, Props, TopicFilters) -> - cast('client_subscribe', [clientinfo(ClientInfo), props(Props), topicfilters(TopicFilters)]). - -on_client_unsubscribe(Clientinfo, Props, TopicFilters) -> - cast('client_unsubscribe', [clientinfo(Clientinfo), props(Props), topicfilters(TopicFilters)]). - -%%-------------------------------------------------------------------- -%% Session -%%-------------------------------------------------------------------- - -on_session_created(ClientInfo, _SessInfo) -> - cast('session_created', [clientinfo(ClientInfo)]). - -on_session_subscribed(Clientinfo, Topic, SubOpts) -> - cast('session_subscribed', [clientinfo(Clientinfo), Topic, props(SubOpts)]). - -on_session_unsubscribed(ClientInfo, Topic, _SubOpts) -> - cast('session_unsubscribed', [clientinfo(ClientInfo), Topic]). - -on_session_resumed(ClientInfo, _SessInfo) -> - cast('session_resumed', [clientinfo(ClientInfo)]). - -on_session_discarded(ClientInfo, _SessInfo) -> - cast('session_discarded', [clientinfo(ClientInfo)]). - -on_session_takeovered(ClientInfo, _SessInfo) -> - cast('session_takeovered', [clientinfo(ClientInfo)]). - -on_session_terminated(ClientInfo, Reason, _SessInfo) -> - cast('session_terminated', [clientinfo(ClientInfo), stringfy(Reason)]). - -%%-------------------------------------------------------------------- -%% Types - -props(undefined) -> []; -props(M) when is_map(M) -> maps:to_list(M). - -conninfo(_ConnInfo = - #{clientid := ClientId, username := Username, peername := {Peerhost, _}, - sockname := {_, SockPort}, proto_name := ProtoName, proto_ver := ProtoVer, - keepalive := Keepalive}) -> - [{node, node()}, - {clientid, ClientId}, - {username, maybe(Username)}, - {peerhost, ntoa(Peerhost)}, - {sockport, SockPort}, - {proto_name, ProtoName}, - {proto_ver, ProtoVer}, - {keepalive, Keepalive}]. - -clientinfo(ClientInfo = - #{clientid := ClientId, username := Username, peerhost := PeerHost, - sockport := SockPort, protocol := Protocol, mountpoint := Mountpoiont}) -> - [{node, node()}, - {clientid, ClientId}, - {username, maybe(Username)}, - {password, maybe(maps:get(password, ClientInfo, undefined))}, - {peerhost, ntoa(PeerHost)}, - {sockport, SockPort}, - {protocol, Protocol}, - {mountpoint, maybe(Mountpoiont)}, - {is_superuser, maps:get(is_superuser, ClientInfo, false)}, - {anonymous, maps:get(anonymous, ClientInfo, true)}]. - -message(#message{id = Id, qos = Qos, from = From, topic = Topic, payload = Payload, timestamp = Ts}) -> - [{node, node()}, - {id, hexstr(Id)}, - {qos, Qos}, - {from, From}, - {topic, Topic}, - {payload, Payload}, - {timestamp, Ts}]. - -topicfilters(Tfs = [{_, _}|_]) -> - [{Topic, Qos} || {Topic, #{qos := Qos}} <- Tfs]; -topicfilters(Tfs) -> - Tfs. - -ntoa({0,0,0,0,0,16#ffff,AB,CD}) -> - list_to_binary(inet_parse:ntoa({AB bsr 8, AB rem 256, CD bsr 8, CD rem 256})); -ntoa(IP) -> - list_to_binary(inet_parse:ntoa(IP)). - -maybe(undefined) -> <<"">>; -maybe(B) -> B. - -%% @private -stringfy(Term) when is_binary(Term) -> - Term; -stringfy(Term) when is_atom(Term) -> - atom_to_binary(Term, utf8); -stringfy(Term) when is_tuple(Term) -> - iolist_to_binary(io_lib:format("~p", [Term])). - -hexstr(B) -> - iolist_to_binary([io_lib:format("~2.16.0B", [X]) || X <- binary_to_list(B)]). - -%%-------------------------------------------------------------------- -%% Validator funcs - -validator(Name) -> - fun(V) -> validate_acc_arg(Name, V) end. - -validate_acc_arg('client_authenticate', V) when is_boolean(V) -> true; -validate_acc_arg('client_check_acl', V) when is_boolean(V) -> true; -validate_acc_arg('message_publish', V) when is_list(V) -> validate_msg(V, true); -validate_acc_arg(_, _) -> false. - -validate_msg([], Bool) -> - Bool; -validate_msg(_, false) -> - false; -validate_msg([{topic, T} | More], _) -> - validate_msg(More, is_binary(T)); -validate_msg([{payload, P} | More], _) -> - validate_msg(More, is_binary(P)); -validate_msg([{qos, Q} | More], _) -> - validate_msg(More, Q =< 2 andalso Q >= 0); -validate_msg([{timestamp, T} | More], _) -> - validate_msg(More, is_integer(T)); -validate_msg([_ | More], _) -> - validate_msg(More, true). - -%%-------------------------------------------------------------------- -%% Misc - -assign_to_message([], Message) -> - Message; -assign_to_message([{topic, Topic}|More], Message) -> - assign_to_message(More, Message#message{topic = Topic}); -assign_to_message([{qos, Qos}|More], Message) -> - assign_to_message(More, Message#message{qos = Qos}); -assign_to_message([{payload, Payload}|More], Message) -> - assign_to_message(More, Message#message{payload = Payload}); -assign_to_message([_|More], Message) -> - assign_to_message(More, Message). diff --git a/apps/emqx_exhook/test/emqx_exhook_SUITE.erl b/apps/emqx_exhook/test/emqx_exhook_SUITE.erl new file mode 100644 index 000000000..b66950215 --- /dev/null +++ b/apps/emqx_exhook/test/emqx_exhook_SUITE.erl @@ -0,0 +1,53 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_exhook_SUITE). + +-compile(export_all). +-compile(nowarn_export_all). + +-include_lib("eunit/include/eunit.hrl"). +-include_lib("common_test/include/ct.hrl"). + +%%-------------------------------------------------------------------- +%% Setups +%%-------------------------------------------------------------------- + +all() -> emqx_ct:all(?MODULE). + +init_per_suite(Cfg) -> + _ = emqx_exhook_demo_svr:start(), + emqx_ct_helpers:start_apps([emqx_exhook], fun set_special_cfgs/1), + Cfg. + +end_per_suite(_Cfg) -> + emqx_ct_helpers:stop_apps([emqx_exhook]), + emqx_exhook_demo_svr:stop(). + +set_special_cfgs(emqx) -> + application:set_env(emqx, allow_anonymous, false), + application:set_env(emqx, enable_acl_cache, false), + application:set_env(emqx, plugins_loaded_file, + emqx_ct_helpers:deps_path(emqx, "test/emqx_SUITE_data/loaded_plugins")); +set_special_cfgs(emqx_exhook) -> + ok. + +%%-------------------------------------------------------------------- +%% Test cases +%%-------------------------------------------------------------------- + +t_hooks(_Cfg) -> + ok. diff --git a/apps/emqx_exhook/test/emqx_exhook_demo_svr.erl b/apps/emqx_exhook/test/emqx_exhook_demo_svr.erl new file mode 100644 index 000000000..05fa07465 --- /dev/null +++ b/apps/emqx_exhook/test/emqx_exhook_demo_svr.erl @@ -0,0 +1,297 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_exhook_demo_svr). + +-behavior(emqx_exhook_v_1_hook_provider_bhvr). + +%% +-export([ start/0 + , stop/0 + , take/0 + , in/1 + ]). + +%% gRPC server HookProvider callbacks +-export([ on_provider_loaded/2 + , on_provider_unloaded/2 + , on_client_connect/2 + , on_client_connack/2 + , on_client_connected/2 + , on_client_disconnected/2 + , on_client_authenticate/2 + , on_client_check_acl/2 + , on_client_subscribe/2 + , on_client_unsubscribe/2 + , on_session_created/2 + , on_session_subscribed/2 + , on_session_unsubscribed/2 + , on_session_resumed/2 + , on_session_discarded/2 + , on_session_takeovered/2 + , on_session_terminated/2 + , on_message_publish/2 + , on_message_delivered/2 + , on_message_dropped/2 + , on_message_acked/2 + ]). + +-define(PORT, 9000). +-define(NAME, ?MODULE). + +%%-------------------------------------------------------------------- +%% Server APIs +%%-------------------------------------------------------------------- + +start() -> + Pid = spawn(fun mngr_main/0), + register(?MODULE, Pid), + {ok, Pid}. + +stop() -> + grpc:stop_server(?NAME), + ?MODULE ! stop. + +take() -> + ?MODULE ! {take, self()}, + receive {value, V} -> V + after 5000 -> error(timeout) end. + +in({FunName, Req}) -> + ?MODULE ! {in, FunName, Req}. + +mngr_main() -> + application:ensure_all_started(grpc), + Services = #{protos => [emqx_exhook_pb], + services => #{'emqx.exhook.v1.HookProvider' => emqx_exhook_demo_svr} + }, + Options = [], + Svr = grpc:start_server(?NAME, ?PORT, Services, Options), + mngr_loop([Svr, queue:new(), queue:new()]). + +mngr_loop([Svr, Q, Takes]) -> + receive + {in, FunName, Req} -> + {NQ1, NQ2} = reply(queue:in({FunName, Req}, Q), Takes), + mngr_loop([Svr, NQ1, NQ2]); + {take, From} -> + {NQ1, NQ2} = reply(Q, queue:in(From, Takes)), + mngr_loop([Svr, NQ1, NQ2]); + stop -> + exit(normal) + end. + +reply(Q1, Q2) -> + case queue:len(Q1) =:= 0 orelse + queue:len(Q2) =:= 0 of + true -> {Q1, Q2}; + _ -> + {{value, {Name, V}}, NQ1} = queue:out(Q1), + {{value, From}, NQ2} = queue:out(Q2), + From ! {value, {Name, V}}, + {NQ1, NQ2} + end. + +%%-------------------------------------------------------------------- +%% callbacks +%%-------------------------------------------------------------------- + +-spec on_provider_loaded(emqx_exhook_pb:provider_loaded_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:loaded_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. + +on_provider_loaded(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{hooks => [ + #{name => <<"client.connect">>}, + #{name => <<"client.connack">>}, + #{name => <<"client.connected">>}, + #{name => <<"client.disconnected">>}, + #{name => <<"client.authenticate">>}, + #{name => <<"client.check_acl">>}, + #{name => <<"client.subscribe">>}, + #{name => <<"client.unsubscribe">>}, + #{name => <<"session.created">>}, + #{name => <<"session.subscribed">>}, + #{name => <<"session.unsubscribed">>}, + #{name => <<"session.resumed">>}, + #{name => <<"session.discarded">>}, + #{name => <<"session.takeovered">>}, + #{name => <<"session.terminated">>}, + #{name => <<"message.publish">>}, + #{name => <<"message.delivered">>}, + #{name => <<"message.acked">>}, + #{name => <<"message.dropped">>}]}, Md}. +-spec on_provider_unloaded(emqx_exhook_pb:provider_unloaded_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_provider_unloaded(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_client_connect(emqx_exhook_pb:client_connect_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_connect(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_client_connack(emqx_exhook_pb:client_connack_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_connack(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_client_connected(emqx_exhook_pb:client_connected_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_connected(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_client_disconnected(emqx_exhook_pb:client_disconnected_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_disconnected(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_client_authenticate(emqx_exhook_pb:client_authenticate_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:valued_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_authenticate(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{type => 'IGNORE'}, Md}. + +-spec on_client_check_acl(emqx_exhook_pb:client_check_acl_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:valued_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_check_acl(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{type => 'STOP_AND_RETURN', value => {bool_result, true}}, Md}. + +-spec on_client_subscribe(emqx_exhook_pb:client_subscribe_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_subscribe(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_client_unsubscribe(emqx_exhook_pb:client_unsubscribe_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_client_unsubscribe(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_session_created(emqx_exhook_pb:session_created_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_session_created(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_session_subscribed(emqx_exhook_pb:session_subscribed_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_session_subscribed(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_session_unsubscribed(emqx_exhook_pb:session_unsubscribed_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_session_unsubscribed(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_session_resumed(emqx_exhook_pb:session_resumed_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_session_resumed(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_session_discarded(emqx_exhook_pb:session_discarded_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_session_discarded(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_session_takeovered(emqx_exhook_pb:session_takeovered_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_session_takeovered(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_session_terminated(emqx_exhook_pb:session_terminated_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_session_terminated(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_message_publish(emqx_exhook_pb:message_publish_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:valued_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_message_publish(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_message_delivered(emqx_exhook_pb:message_delivered_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_message_delivered(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_message_dropped(emqx_exhook_pb:message_dropped_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_message_dropped(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_message_acked(emqx_exhook_pb:message_acked_request(), grpc:metadata()) + -> {ok, emqx_exhook_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_message_acked(Req, Md) -> + ?MODULE:in({?FUNCTION_NAME, Req}), + %io:format("fun: ~p, req: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. diff --git a/apps/emqx_exhook/test/emqx_extension_hook_SUITE.erl b/apps/emqx_exhook/test/emqx_extension_hook_SUITE.erl deleted file mode 100644 index 11237b7fd..000000000 --- a/apps/emqx_exhook/test/emqx_extension_hook_SUITE.erl +++ /dev/null @@ -1,139 +0,0 @@ -%%-------------------------------------------------------------------- -%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. -%% -%% Licensed under the Apache License, Version 2.0 (the "License"); -%% you may not use this file except in compliance with the License. -%% You may obtain a copy of the License at -%% -%% http://www.apache.org/licenses/LICENSE-2.0 -%% -%% Unless required by applicable law or agreed to in writing, software -%% distributed under the License is distributed on an "AS IS" BASIS, -%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -%% See the License for the specific language governing permissions and -%% limitations under the License. -%%-------------------------------------------------------------------- - --module(emqx_extension_hook_SUITE). - --compile(export_all). --compile(nowarn_export_all). - --include_lib("eunit/include/eunit.hrl"). --include_lib("common_test/include/ct.hrl"). - -%%-------------------------------------------------------------------- -%% Setups -%%-------------------------------------------------------------------- - -all() -> emqx_ct:all(?MODULE). - -init_per_suite(Cfg) -> - emqx_ct_helpers:start_apps([emqx_extension_hook], fun set_special_cfgs/1), - emqx_logger:set_log_level(warning), - Cfg. - -end_per_suite(_) -> - emqx_ct_helpers:stop_apps([emqx_extension_hook]). - -set_special_cfgs(emqx) -> - application:set_env(emqx, allow_anonymous, false), - application:set_env(emqx, enable_acl_cache, false), - application:set_env(emqx, plugins_loaded_file, - emqx_ct_helpers:deps_path(emqx, "test/emqx_SUITE_data/loaded_plugins")); -set_special_cfgs(emqx_extension_hook) -> - application:set_env(emqx_extension_hook, drivers, []), - ok. - -reload_plugin_with(_DriverName = python3) -> - application:stop(emqx_extension_hook), - Path = emqx_ct_helpers:deps_path(emqx_extension_hook, "test/scripts"), - Drivers = [{python3, [{init_module, main}, - {python_path, Path}, - {call_timeout, 5000}]}], - application:set_env(emqx_extension_hook, drivers, Drivers), - application:ensure_all_started(emqx_extension_hook); - -reload_plugin_with(_DriverName = java) -> - application:stop(emqx_extension_hook), - - ErlPortJar = emqx_ct_helpers:deps_path(erlport, "priv/java/_pkgs/erlport.jar"), - Path = emqx_ct_helpers:deps_path(emqx_extension_hook, "test/scripts"), - Drivers = [{java, [{init_module, 'Main'}, - {java_path, Path}, - {call_timeout, 5000}]}], - - %% Compile it - ct:pal(os:cmd(lists:concat(["cd ", Path, " && ", - "rm -rf Main.class State.class && ", - "javac -cp ", ErlPortJar, " Main.java"]))), - - application:set_env(emqx_extension_hook, drivers, Drivers), - application:ensure_all_started(emqx_extension_hook). - -%%-------------------------------------------------------------------- -%% Test cases -%%-------------------------------------------------------------------- - -t_python3(_) -> - reload_plugin_with(python3), - schedule_all_hooks(). - -t_java(_) -> - reload_plugin_with(java), - schedule_all_hooks(). - -schedule_all_hooks() -> - ok = emqx_extension_hook_handler:on_client_connect(conninfo(), #{}), - ok = emqx_extension_hook_handler:on_client_connack(conninfo(), success,#{}), - ok = emqx_extension_hook_handler:on_client_connected(clientinfo(), conninfo()), - ok = emqx_extension_hook_handler:on_client_disconnected(clientinfo(), takeovered, conninfo()), - {stop, #{auth_result := success, - anonymous := false}} = emqx_extension_hook_handler:on_client_authenticate(clientinfo(), #{auth_result => not_authorised, anonymous => true}), - {stop, allow} = emqx_extension_hook_handler:on_client_check_acl(clientinfo(), publish, <<"t/a">>, deny), - ok = emqx_extension_hook_handler:on_client_subscribe(clientinfo(), #{}, sub_topicfilters()), - ok = emqx_extension_hook_handler:on_client_unsubscribe(clientinfo(), #{}, unsub_topicfilters()), - - ok = emqx_extension_hook_handler:on_session_created(clientinfo(), sessinfo()), - ok = emqx_extension_hook_handler:on_session_subscribed(clientinfo(), <<"t/a">>, subopts()), - ok = emqx_extension_hook_handler:on_session_unsubscribed(clientinfo(), <<"t/a">>, subopts()), - ok = emqx_extension_hook_handler:on_session_resumed(clientinfo(), sessinfo()), - ok = emqx_extension_hook_handler:on_session_discarded(clientinfo(), sessinfo()), - ok = emqx_extension_hook_handler:on_session_takeovered(clientinfo(), sessinfo()), - ok = emqx_extension_hook_handler:on_session_terminated(clientinfo(), sockerr, sessinfo()). - -%%-------------------------------------------------------------------- -%% Generator -%%-------------------------------------------------------------------- - -conninfo() -> - #{clientid => <<"123">>, - username => <<"abc">>, - peername => {{127,0,0,1}, 2341}, - sockname => {{0,0,0,0}, 1883}, - proto_name => <<"MQTT">>, - proto_ver => 4, - keepalive => 60 - }. - -clientinfo() -> - #{clientid => <<"123">>, - username => <<"abc">>, - peerhost => {127,0,0,1}, - sockport => 1883, - protocol => 'mqtt', - mountpoint => undefined - }. - -sub_topicfilters() -> - [{<<"t/a">>, #{qos => 1}}]. - -unsub_topicfilters() -> - [<<"t/a">>]. - -sessinfo() -> - {session,xxx,yyy}. - -subopts() -> - #{qos => 1, rh => 0, rap => 0, nl => 0}. - diff --git a/apps/emqx_exhook/test/props/prop_exhook_hooks.erl b/apps/emqx_exhook/test/props/prop_exhook_hooks.erl new file mode 100644 index 000000000..e4c11dd3d --- /dev/null +++ b/apps/emqx_exhook/test/props/prop_exhook_hooks.erl @@ -0,0 +1,537 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(prop_exhook_hooks). + +-include_lib("proper/include/proper.hrl"). +-include_lib("eunit/include/eunit.hrl"). + +-import(emqx_ct_proper_types, + [ conninfo/0 + , clientinfo/0 + , sessioninfo/0 + , message/0 + , connack_return_code/0 + , topictab/0 + , topic/0 + , subopts/0 + ]). + +-define(ALL(Vars, Types, Exprs), + ?SETUP(fun() -> + State = do_setup(), + fun() -> do_teardown(State) end + end, ?FORALL(Vars, Types, Exprs))). + +%%-------------------------------------------------------------------- +%% Properties +%%-------------------------------------------------------------------- + +prop_client_connect() -> + ?ALL({ConnInfo, ConnProps}, + {conninfo(), conn_properties()}, + begin + _OutConnProps = emqx_hooks:run_fold('client.connect', [ConnInfo], ConnProps), + {'on_client_connect', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{props => properties(ConnProps), + conninfo => + #{node => nodestr(), + clientid => maps:get(clientid, ConnInfo), + username => maybe(maps:get(username, ConnInfo, <<>>)), + peerhost => peerhost(ConnInfo), + sockport => sockport(ConnInfo), + proto_name => maps:get(proto_name, ConnInfo), + proto_ver => stringfy(maps:get(proto_ver, ConnInfo)), + keepalive => maps:get(keepalive, ConnInfo) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_client_connack() -> + ?ALL({ConnInfo, Rc, AckProps}, + {conninfo(), connack_return_code(), ack_properties()}, + begin + _OutAckProps = emqx_hooks:run_fold('client.connack', [ConnInfo, Rc], AckProps), + {'on_client_connack', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{props => properties(AckProps), + result_code => atom_to_binary(Rc, utf8), + conninfo => + #{node => nodestr(), + clientid => maps:get(clientid, ConnInfo), + username => maybe(maps:get(username, ConnInfo, <<>>)), + peerhost => peerhost(ConnInfo), + sockport => sockport(ConnInfo), + proto_name => maps:get(proto_name, ConnInfo), + proto_ver => stringfy(maps:get(proto_ver, ConnInfo)), + keepalive => maps:get(keepalive, ConnInfo) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_client_authenticate() -> + ?ALL({ClientInfo, AuthResult}, {clientinfo(), authresult()}, + begin + _OutAuthResult = emqx_hooks:run_fold('client.authenticate', [ClientInfo], AuthResult), + {'on_client_authenticate', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{result => authresult_to_bool(AuthResult), + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_client_check_acl() -> + ?ALL({ClientInfo, PubSub, Topic, Result}, + {clientinfo(), oneof([publish, subscribe]), topic(), oneof([allow, deny])}, + begin + _OutResult = emqx_hooks:run_fold('client.check_acl', [ClientInfo, PubSub, Topic], Result), + {'on_client_check_acl', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{result => aclresult_to_bool(Result), + type => pubsub_to_enum(PubSub), + topic => Topic, + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + + +prop_client_connected() -> + ?ALL({ClientInfo, ConnInfo}, + {clientinfo(), conninfo()}, + begin + ok = emqx_hooks:run('client.connected', [ClientInfo, ConnInfo]), + {'on_client_connected', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_client_disconnected() -> + ?ALL({ClientInfo, Reason, ConnInfo}, + {clientinfo(), shutdown_reason(), conninfo()}, + begin + ok = emqx_hooks:run('client.disconnected', [ClientInfo, Reason, ConnInfo]), + {'on_client_disconnected', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{reason => stringfy(Reason), + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_client_subscribe() -> + ?ALL({ClientInfo, SubProps, TopicTab}, + {clientinfo(), sub_properties(), topictab()}, + begin + _OutTopicTab = emqx_hooks:run_fold('client.subscribe', [ClientInfo, SubProps], TopicTab), + {'on_client_subscribe', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{props => properties(SubProps), + topic_filters => topicfilters(TopicTab), + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_client_unsubscribe() -> + ?ALL({ClientInfo, UnSubProps, TopicTab}, + {clientinfo(), unsub_properties(), topictab()}, + begin + _OutTopicTab = emqx_hooks:run_fold('client.unsubscribe', [ClientInfo, UnSubProps], TopicTab), + {'on_client_unsubscribe', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{props => properties(UnSubProps), + topic_filters => topicfilters(TopicTab), + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_session_created() -> + ?ALL({ClientInfo, SessInfo}, {clientinfo(), sessioninfo()}, + begin + ok = emqx_hooks:run('session.created', [ClientInfo, SessInfo]), + {'on_session_created', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_session_subscribed() -> + ?ALL({ClientInfo, Topic, SubOpts}, + {clientinfo(), topic(), subopts()}, + begin + ok = emqx_hooks:run('session.subscribed', [ClientInfo, Topic, SubOpts]), + {'on_session_subscribed', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{topic => Topic, + subopts => subopts(SubOpts), + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_session_unsubscribed() -> + ?ALL({ClientInfo, Topic, SubOpts}, + {clientinfo(), topic(), subopts()}, + begin + ok = emqx_hooks:run('session.unsubscribed', [ClientInfo, Topic, SubOpts]), + {'on_session_unsubscribed', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{topic => Topic, + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_session_resumed() -> + ?ALL({ClientInfo, SessInfo}, {clientinfo(), sessioninfo()}, + begin + ok = emqx_hooks:run('session.resumed', [ClientInfo, SessInfo]), + {'on_session_resumed', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_session_discared() -> + ?ALL({ClientInfo, SessInfo}, {clientinfo(), sessioninfo()}, + begin + ok = emqx_hooks:run('session.discarded', [ClientInfo, SessInfo]), + {'on_session_discarded', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_session_takeovered() -> + ?ALL({ClientInfo, SessInfo}, {clientinfo(), sessioninfo()}, + begin + ok = emqx_hooks:run('session.takeovered', [ClientInfo, SessInfo]), + {'on_session_takeovered', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + true + end). + +prop_session_terminated() -> + ?ALL({ClientInfo, Reason, SessInfo}, + {clientinfo(), shutdown_reason(), sessioninfo()}, + begin + ok = emqx_hooks:run('session.terminated', [ClientInfo, Reason, SessInfo]), + {'on_session_terminated', Resp} = emqx_exhook_demo_svr:take(), + Expected = + #{reason => stringfy(Reason), + clientinfo => + #{node => nodestr(), + clientid => maps:get(clientid, ClientInfo), + username => maybe(maps:get(username, ClientInfo, <<>>)), + password => maybe(maps:get(password, ClientInfo, <<>>)), + peerhost => ntoa(maps:get(peerhost, ClientInfo)), + sockport => maps:get(sockport, ClientInfo), + protocol => stringfy(maps:get(protocol, ClientInfo)), + mountpoint => maybe(maps:get(mountpoint, ClientInfo, <<>>)), + is_superuser => maps:get(is_superuser, ClientInfo, false), + anonymous => maps:get(anonymous, ClientInfo, true) + } + }, + ?assertEqual(Expected, Resp), + + true + end). + +nodestr() -> + stringfy(node()). + +peerhost(#{peername := {Host, _}}) -> + ntoa(Host). + +sockport(#{sockname := {_, Port}}) -> + Port. + +%% copied from emqx_exhook + +ntoa({0,0,0,0,0,16#ffff,AB,CD}) -> + list_to_binary(inet_parse:ntoa({AB bsr 8, AB rem 256, CD bsr 8, CD rem 256})); +ntoa(IP) -> + list_to_binary(inet_parse:ntoa(IP)). + +maybe(undefined) -> <<>>; +maybe(B) -> B. + +properties(undefined) -> []; +properties(M) when is_map(M) -> + maps:fold(fun(K, V, Acc) -> + [#{name => stringfy(K), + value => stringfy(V)} | Acc] + end, [], M). + +topicfilters(Tfs) when is_list(Tfs) -> + [#{name => Topic, qos => Qos} || {Topic, #{qos := Qos}} <- Tfs]. + +%% @private +stringfy(Term) when is_binary(Term) -> + Term; +stringfy(Term) when is_integer(Term) -> + integer_to_binary(Term); +stringfy(Term) when is_atom(Term) -> + atom_to_binary(Term, utf8); +stringfy(Term) -> + unicode:characters_to_binary((io_lib:format("~0p", [Term]))). + +subopts(SubOpts) -> + #{qos => maps:get(qos, SubOpts, 0), + rh => maps:get(rh, SubOpts, 0), + rap => maps:get(rap, SubOpts, 0), + nl => maps:get(nl, SubOpts, 0), + share => maps:get(share, SubOpts, <<>>) + }. + +authresult_to_bool(AuthResult) -> + maps:get(auth_result, AuthResult, undefined) == success. + +aclresult_to_bool(Result) -> + Result == allow. + +pubsub_to_enum(publish) -> 'PUBLISH'; +pubsub_to_enum(subscribe) -> 'SUBSCRIBE'. + +%prop_message_publish() -> +% ?ALL({Msg, Env, Encode}, {message(), topic_filter_env()}, +% begin +% true +% end). +% +%prop_message_delivered() -> +% ?ALL({ClientInfo, Msg, Env, Encode}, {clientinfo(), message(), topic_filter_env()}, +% begin +% true +% end). +% +%prop_message_acked() -> +% ?ALL({ClientInfo, Msg, Env, Encode}, {clientinfo(), message()}, +% begin +% true +% end). + +%%-------------------------------------------------------------------- +%% Helper +%%-------------------------------------------------------------------- + +do_setup() -> + _ = emqx_exhook_demo_svr:start(), + emqx_ct_helpers:start_apps([emqx_exhook], fun set_special_cfgs/1), + emqx_logger:set_log_level(warning), + %% waiting first loaded event + {'on_provider_loaded', _} = emqx_exhook_demo_svr:take(), + ok. + +do_teardown(_) -> + emqx_ct_helpers:stop_apps([emqx_exhook]), + %% waiting last unloaded event + {'on_provider_unloaded', _} = emqx_exhook_demo_svr:take(), + _ = emqx_exhook_demo_svr:stop(), + timer:sleep(2000), + ok. + +set_special_cfgs(emqx) -> + application:set_env(emqx, allow_anonymous, false), + application:set_env(emqx, enable_acl_cache, false), + application:set_env(emqx, plugins_loaded_file, + emqx_ct_helpers:deps_path(emqx, "test/emqx_SUITE_data/loaded_plugins")); +set_special_cfgs(emqx_exhook) -> + ok. + +%%-------------------------------------------------------------------- +%% Generators +%%-------------------------------------------------------------------- + +conn_properties() -> + #{}. + +ack_properties() -> + #{}. + +sub_properties() -> + #{}. + +unsub_properties() -> + #{}. + +shutdown_reason() -> + oneof([utf8(), {shutdown, atom()}]). + +authresult() -> + #{auth_result => connack_return_code()}. + +%topic_filter_env() -> +% oneof([{<<"#">>}, {undefined}, {topic()}]). diff --git a/apps/emqx_exhook/test/scripts/Main.java b/apps/emqx_exhook/test/scripts/Main.java deleted file mode 100644 index 996b729f6..000000000 --- a/apps/emqx_exhook/test/scripts/Main.java +++ /dev/null @@ -1,160 +0,0 @@ -import java.io.*; -import java.util.*; -import com.erlport.erlang.term.*; - -class State implements Serializable { - - Integer times; - - public State() { - times = 0; - } - - public Integer incr() { - times += 1; - return times; - } - - @Override - public String toString() { - return String.format("State(times: %d)", times); - } -} - -public class Main { - - public static Object init() { - System.err.printf("Initiate driver...\n"); - - // [{"topics", ["t/#", "t/a"]}] - List topics = new ArrayList(); - topics.add(new Binary("t/#")); - topics.add(new Binary("test/#")); - - List actionOpts = new ArrayList(); - actionOpts.add(Tuple.two(new Atom("topics"), topics)); - - Object[] actions0 = new Object[] { - Tuple.three("client_connect", "Main", "on_client_connect"), - Tuple.three("client_connack", "Main", "on_client_connack"), - Tuple.three("client_connected", "Main", "on_client_connected"), - Tuple.three("client_disconnected", "Main", "on_client_disconnected"), - Tuple.three("client_authenticate", "Main", "on_client_authenticate"), - Tuple.three("client_check_acl", "Main", "on_client_check_acl"), - Tuple.three("client_subscribe", "Main", "on_client_subscribe"), - Tuple.three("client_unsubscribe", "Main", "on_client_unsubscribe"), - - Tuple.three("session_created", "Main", "on_session_created"), - Tuple.three("session_subscribed", "Main", "on_session_subscribed"), - Tuple.three("session_unsubscribed", "Main", "on_session_unsubscribed"), - Tuple.three("session_resumed", "Main", "on_session_resumed"), - Tuple.three("session_discarded", "Main", "on_session_discarded"), - Tuple.three("session_takeovered", "Main", "on_session_takeovered"), - Tuple.three("session_terminated", "Main", "on_session_terminated"), - - Tuple.four("message_publish", "Main", "on_message_publish", actionOpts), - Tuple.four("message_delivered", "Main", "on_message_delivered", actionOpts), - Tuple.four("message_acked", "Main", "on_message_acked", actionOpts), - Tuple.four("message_dropped", "Main", "on_message_dropped", actionOpts) - }; - - List actions = new ArrayList(Arrays.asList(actions0)); - - State state = new State(); - //Tuple state = new Tuple(0); - - // {0 | 1, [{HookName, CallModule, CallFunction, Opts}]} - return Tuple.two(0, Tuple.two(actions, state)); - } - - public static void deinit() { - - } - - // Callbacks - - public static void on_client_connect(Object connInfo, Object props, Object state) { - System.err.printf("[Java] on_client_connect: connInfo: %s, props: %s, state: %s\n", connInfo, props, state); - } - - public static void on_client_connack(Object connInfo, Object rc, Object props, Object state) { - System.err.printf("[Java] on_client_connack: connInfo: %s, rc: %s, props: %s, state: %s\n", connInfo, rc, props, state); - } - - public static void on_client_connected(Object clientInfo, Object state) { - System.err.printf("[Java] on_client_connected: clientinfo: %s, state: %s\n", clientInfo, state); - } - - public static void on_client_disconnected(Object clientInfo, Object reason, Object state) { - System.err.printf("[Java] on_client_disconnected: clientinfo: %s, reason: %s, state: %s\n", clientInfo, reason, state); - } - - public static Object on_client_authenticate(Object clientInfo, Object authresult, Object state) { - System.err.printf("[Java] on_client_authenticate: clientinfo: %s, authresult: %s, state: %s\n", clientInfo, authresult, state); - - return Tuple.two(0, true); - } - - public static Object on_client_check_acl(Object clientInfo, Object pubsub, Object topic, Object result, Object state) { - System.err.printf("[Java] on_client_check_acl: clientinfo: %s, pubsub: %s, topic: %s, result: %s, state: %s\n", clientInfo, pubsub, topic, result, state); - - return Tuple.two(0, true); - } - - public static void on_client_subscribe(Object clientInfo, Object props, Object topic, Object state) { - System.err.printf("[Java] on_client_subscribe: clientinfo: %s, props: %s, topic: %s, state: %s\n", clientInfo, props, topic, state); - } - - public static void on_client_unsubscribe(Object clientInfo, Object props, Object topic, Object state) { - System.err.printf("[Java] on_client_unsubscribe: clientinfo: %s, props: %s, topic: %s, state: %s\n", clientInfo, props, topic, state); - } - - // Sessions - - public static void on_session_created(Object clientInfo, Object state) { - System.err.printf("[Java] on_session_created: clientinfo: %s, state: %s\n", clientInfo, state); - } - - public static void on_session_subscribed(Object clientInfo, Object topic, Object opts, Object state) { - System.err.printf("[Java] on_session_subscribed: clientinfo: %s, topic: %s, subopts: %s, state: %s\n", clientInfo, topic, opts, state); - } - - public static void on_session_unsubscribed(Object clientInfo, Object topic, Object state) { - System.err.printf("[Java] on_session_unsubscribed: clientinfo: %s, topic: %s, state: %s\n", clientInfo, topic, state); - } - - public static void on_session_resumed(Object clientInfo, Object state) { - System.err.printf("[Java] on_session_resumed: clientinfo: %s, state: %s\n", clientInfo, state); - } - - public static void on_session_discarded(Object clientInfo, Object state) { - System.err.printf("[Java] on_session_discarded: clientinfo: %s, state: %s\n", clientInfo, state); - } - - public static void on_session_takeovered(Object clientInfo, Object state) { - System.err.printf("[Java] on_session_takeovered: clientinfo: %s, state: %s\n", clientInfo, state); - } - - public static void on_session_terminated(Object clientInfo, Object reason, Object state) { - System.err.printf("[Java] on_session_terminated: clientinfo: %s, reason: %s, state: %s\n", clientInfo, reason, state); - } - - // Messages - - public static Object on_message_publish(Object message, Object state) { - System.err.printf("[Java] on_message_publish: message: %s, state: %s\n", message, state); - return Tuple.two(0, message); - } - - public static void on_message_dropped(Object message, Object reason, Object state) { - System.err.printf("[Java] on_message_dropped: message: %s, reason: %s, state: %s\n", message, reason, state); - } - - public static void on_message_delivered(Object clientInfo, Object message, Object state) { - System.err.printf("[Java] on_message_delivered: clientinfo: %s, message: %s, state: %s\n", clientInfo, message, state); - } - - public static void on_message_acked(Object clientInfo, Object message, Object state) { - System.err.printf("[Java] on_message_acked: clientinfo: %s, message: %s, state: %s\n", clientInfo, message, state); - } -} diff --git a/apps/emqx_exhook/test/scripts/main.py b/apps/emqx_exhook/test/scripts/main.py deleted file mode 100644 index dcfa1d7e4..000000000 --- a/apps/emqx_exhook/test/scripts/main.py +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/python -# -*- coding: UTF-8 -*- - -OK = 0 -ERROR = 1 - -## Return :: (HookSpec, State) -## -## HookSpec :: [(HookName, CallbackModule, CallbackFunction, Opts)] -## State :: Any -## -## HookName :: "client_connect" | "client_connack" | "client_connected" | ... -## CallbackModule :: ... -## CallbackFunctiin :: ... -## Opts :: [(Key, Value)] -def init(): - ## Maybe a connection object? - state = () - hookspec = [("client_connect", "main", "on_client_connect", []), - ("client_connack", "main", "on_client_connack", []), - ("client_connected", "main", "on_client_connected", []), - ("client_disconnected", "main", "on_client_disconnected", []), - ("client_authenticate", "main", "on_client_authenticate", []), - ("client_check_acl", "main", "on_client_check_acl", []), - ("client_subscribe", "main", "on_client_subscribe", []), - ("client_unsubscribe", "main", "on_client_unsubscribe", []), - ("session_created", "main", "on_session_created", []), - ("session_subscribed", "main", "on_session_subscribed", []), - ("session_unsubscribed","main", "on_session_unsubscribed", []), - ("session_resumed", "main", "on_session_resumed", []), - ("session_discarded", "main", "on_session_discarded", []), - ("session_takeovered", "main", "on_session_takeovered", []), - ("session_terminated", "main", "on_session_terminated", []), - ("message_publish", "main", "on_message_publish", [("topics", ["t/#"])]), - ("message_delivered", "main", "on_message_delivered", [("topics", ["t/#"])]), - ("message_acked", "main", "on_message_acked", [("topics", ["t/#"])]), - ("message_dropped", "main", "on_message_dropped", [("topics", ["t/#"])]) - ] - return (OK, (hookspec, state)) - -def deinit(): - return - -##-------------------------------------------------------------------- -## Callback functions -##-------------------------------------------------------------------- - - -##-------------------------------------------------------------------- -## Clients - -def on_client_connect(conninfo, props, state): - print("on_client_connect: conninfo: {0}, props: {1}, state: {2}".format(conninfo, props, state)) - return - -def on_client_connack(conninfo, rc, props, state): - print("on_client_connack: conninfo: {0}, rc{1}, props: {2}, state: {3}".format(conninfo, rc, props, state)) - return - -def on_client_connected(clientinfo, state): - print("on_client_connected: clientinfo: {0}, state: {1}".format(clientinfo, state)) - return - -def on_client_disconnected(clientinfo, reason, state): - print("on_client_disconnected: clientinfo: {0}, reason: {1}, state: {2}".format(clientinfo, reason, state)) - return - -def on_client_authenticate(clientinfo, authresult, state): - print("on_client_authenticate: clientinfo: {0}, authresult: {1}, state: {2}".format(clientinfo, authresult, state)) - ## True / False - return (OK, True) - -def on_client_check_acl(clientinfo, pubsub, topic, result, state): - print("on_client_check_acl: clientinfo: {0}, pubsub: {1}, topic: {2}, result: {3}, state: {4}".format(clientinfo, pubsub, topic, result, state)) - ## True / False - return (OK, True) - -def on_client_subscribe(clientinfo, props, topics, state): - print("on_client_subscribe: clientinfo: {0}, props: {1}, topics: {2}, state: {3}".format(clientinfo, props, topics, state)) - return - -def on_client_unsubscribe(clientinfo, props, topics, state): - print("on_client_unsubscribe: clientinfo: {0}, props: {1}, topics: {2}, state: {3}".format(clientinfo, props, topics, state)) - return - -##-------------------------------------------------------------------- -## Sessions - -def on_session_created(clientinfo, state): - print("on_session_created: clientinfo: {0}, state: {1}".format(clientinfo, state)) - return - -def on_session_subscribed(clientinfo, topic, opts, state): - print("on_session_subscribed: clientinfo: {0}, topic: {1}, opts: {2}, state: {3}".format(clientinfo, topic, opts, state)) - return - -def on_session_unsubscribed(clientinfo, topic, state): - print("on_session_unsubscribed: clientinfo: {0}, topic: {1}, state: {2}".format(clientinfo, topic, state)) - return - -def on_session_resumed(clientinfo, state): - print("on_session_resumed: clientinfo: {0}, state: {1}".format(clientinfo, state)) - return - -def on_session_discarded(clientinfo, state): - print("on_session_discared: clientinfo: {0}, state: {1}".format(clientinfo, state)) - return - -def on_session_takeovered(clientinfo, state): - print("on_session_takeovered: clientinfo: {0}, state: {1}".format(clientinfo, state)) - return - -def on_session_terminated(clientinfo, reason, state): - print("on_session_terminated: clientinfo: {0}, reason: {1}, state: {2}".format(clientinfo, reason, state)) - return - -##-------------------------------------------------------------------- -## Messages - -def on_message_publish(message, state): - print("on_message_publish: message: {0}, state: {1}".format(message, state)) - return message - -def on_message_dropped(message, reason, state): - print("on_message_dropped: message: {0}, reason: {1}, state: {2}".format(message, reason, state)) - return - -def on_message_delivered(clientinfo, message, state): - print("on_message_delivered: clientinfo: {0}, message: {1}, state: {2}".format(clientinfo, message, state)) - return - -def on_message_acked(clientinfo, message, state): - print("on_message_acked: clientinfo: {0}, message: {1}, state: {2}".format(clientinfo, message, state)) - return diff --git a/apps/emqx_exproto/.gitignore b/apps/emqx_exproto/.gitignore index b193aa17d..384f2255a 100644 --- a/apps/emqx_exproto/.gitignore +++ b/apps/emqx_exproto/.gitignore @@ -41,5 +41,8 @@ erlang.mk *.coverdata etc/emqx_exproto.conf.rendered Mnesia.*/ -__pycache__ -example/*.class +src/emqx_exproto_pb.erl +src/emqx_exproto_v_1_connection_adapter_bhvr.erl +src/emqx_exproto_v_1_connection_adapter_client.erl +src/emqx_exproto_v_1_connection_handler_bhvr.erl +src/emqx_exproto_v_1_connection_handler_client.erl diff --git a/apps/emqx_exproto/README.md b/apps/emqx_exproto/README.md index 7fa88a9dc..4b59dcae3 100644 --- a/apps/emqx_exproto/README.md +++ b/apps/emqx_exproto/README.md @@ -4,53 +4,25 @@ The `emqx_exproto` extremly enhance the extensibility for EMQ X. It allow using ## Feature -- [x] Support Python, Java. -- [x] Support the `tcp`, `ssl`, `udp`, `dtls` socket. -- [x] Provide the `PUB/SUB` interface to others language. - -We temporarily no plans to support other languages. Plaease open a issue if you have to use other programming languages. +- [x] Based on gRPC, it brings a very wide range of applicability +- [x] Allows you to use the return value to extend emqx behavior. ## Architecture ![EMQ X ExProto Arch](./docs/images/exproto-arch.jpg) -## Drivers +## Usage -### Python +### gRPC service -***Requirements:*** +See: `priv/protos/exproto.proto` -- It requires the emqx hosted machine has Python3 Runtimes -- An executable commands in your shell, i,g: `python3` or `python` +## Example -***Examples:*** +## Recommended gRPC Framework -See [example/main.python](https://github.com/emqx/emqx-exproto/blob/master/example/main.py) +See: https://github.com/grpc-ecosystem/awesome-grpc -### Java +## Thanks -See [example/Main.java](https://github.com/emqx/emqx-exproto/blob/master/example/Main.java) - - -## SDK - -The SDK encloses the underlying obscure data types and function interfaces. It only provides a convenience for development, it is not required. - -See [sdk/README.md](https://github.com/emqx/emqx-exproto/blob/master/sdk/README.md) - - -## Benchmark - -***Work in progress...*** - - -## Known Issues or TODOs - -- Configurable Log System. - * The Java driver can not redirect the `stderr` stream to erlang vm on Windows platform - -## Reference - -- [erlport](https://github.com/hdima/erlport) -- [External Term Format](http://erlang.org/doc/apps/erts/erl_ext_dist.html) -- [The Ports Tutorial of Erlang](http://erlang.org/doc/tutorial/c_port.html) +- [grpcbox](https://github.com/tsloughter/grpcbox) diff --git a/apps/emqx_exproto/docs/design.md b/apps/emqx_exproto/docs/design.md index b5cc4e49d..0a6a082e2 100644 --- a/apps/emqx_exproto/docs/design.md +++ b/apps/emqx_exproto/docs/design.md @@ -4,173 +4,124 @@ 该插件给 EMQ X 带来的扩展性十分的强大,它能以你熟悉语言处理任何的私有协议,并享受由 EMQ X 系统带来的高连接,和高并发的优点。 -**声明:当前仅实现了 Python、Java 的支持** - ## 特性 -- 多语言支持。快速将接入层的协议实现迁移到 EMQ X 中进行管理 +- 极强的扩展能力。使用 gRPC 作为 RPC 通信框架,支持各个主流编程语言 - 高吞吐。连接层以完全的异步非阻塞式 I/O 的方式实现 -- 完善的连接层。完全的支持 TCP\TLS UDP\DTLS 类型的连接 +- 连接层透明。完全的支持 TCP\TLS UDP\DTLS 类型的连接管理,并对上层提供统一个 API - 连接层的管理能力。例如,最大连接数,连接和吞吐的速率限制,IP 黑名单 等 -## 架构 +## 架构 ![Extension-Protocol Arch](images/exproto-arch.jpg) -该插件需要完成的工作包括三部分: +该插件主要需要处理的内容包括: -**初始化:** (TODO) -- loaded: -- unload: +1. **连接层:** 该部分主要**维持 Socket 的生命周期,和数据的收发**。它的功能要求包括: + - 监听某个端口。当有新的 TCP/UDP 连接到达后,启动一个连接进程,来维持连接的状态。 + - 调用 `OnSocketCreated` 回调。用于通知外部模块**已新建立了一个连接**。 + - 调用 `OnScoektClosed` 回调。用于通知外部模块连接**已关闭**。 + - 调用 `OnReceivedBytes` 回调。用于通知外部模块**该连接新收到的数据包**。 + - 提供 `Send` 接口。供外部模块调用,**用于发送数据包**。 + - 提供 `Close` 接口。供外部模块调用,**用于主动关闭连接**。 -**连接层:** 该部分主要**维持 Socket 的生命周期,和数据的收发**。它的功能要求包括: +2. **协议/会话层:**该部分主要**提供 PUB/SUB 接口**,以实现与 EMQ X Broker 系统的消息互通。包括: -- 监听某个端口。当有新的 TCP/UDP 连接到达后,启动一个连接进程,来维持连接的状态。 -- 调用 `init` 回调。用于通知外部模块**已新建立了一个连接**。 -- 调用 `terminated` 回调。用于通知外部模块连接**已关闭**。 -- 调用 `received` 回调。用于通知外部模块**该连接新收到的数据包**。 -- 提供 `send` 接口。供外部模块调用,**用于发送数据包**。 -- 提供 `close` 接口。供外部模块调用,**用于主动关闭连接**。 - - -**协议/会话层:**该部分主要**提供 PUB/SUB 接口**,以实现与 EMQ X Broker 系统的消息互通。包括: - -- 提供 `register` 接口。供外部模块调用,用于向集群注册客户端。 -- 提供 `publish` 接口。供外部模块调用,用于发布消息 EMQ X Broker 中。 -- 提供 `subscribe` 接口。供外部模块调用,用于订阅某主题,以实现从 EMQ X Broker 中接收某些下行消息。 -- 提供 `unsubscribe` 接口。供外部模块调用,用于取消订阅某主题。 -- 调用 `deliver` 回调。用于接收下行消息(在订阅主题成功后,如果主题上有消息,便会回调该方法) - - -**管理&统计相关:** 该部分主要提供其他**管理&统计相关的接口**。包括: - -- 提供 `Hooks` 类的接口。用于与系统的钩子系统进行交互。 -- 提供 `Metrics` 类的接口。用于统计。 -- 提供 `HTTP or CLI` 管理类接口。 + - 提供 `Authenticate` 接口。供外部模块调用,用于向集群注册客户端。 + - 提供 `StartTimer` 接口。供外部模块调用,用于为该连接进程启动心跳等定时器。 + - 提供 `Publish` 接口。供外部模块调用,用于发布消息 EMQ X Broker 中。 + - 提供 `Subscribe` 接口。供外部模块调用,用于订阅某主题,以实现从 EMQ X Broker 中接收某些下行消息。 + - 提供 `Unsubscribe` 接口。供外部模块调用,用于取消订阅某主题。 + - 调用 `OnTimerTimeout` 回调。用于处理定时器超时的事件。 + - 调用 `OnReceivedMessages` 回调。用于接收下行消息(在订阅主题成功后,如果主题上有消息,便会回调该方法) ## 接口设计 -### 连接层接口 +从 gRPC 上的逻辑来说,emqx-exproto 会作为客户端向用户的 `ProtocolHandler` 服务发送回调请求。同时,它也会作为服务端向用户提供 `ConnectionAdapter` 服务,以提供 emqx-exproto 各个接口的访问。如图: -多语言组件需要向 EMQ X 注册的回调函数: +![Extension Protocol gRPC Arch](images/exproto-grpc-arch.jpg) -```erlang -%% Got a new Connection -init(conn(), conninfo()) -> state(). -%% Incoming a data -recevied(conn(), data(), state()) -> state(). +详情参见:`priv/protos/exproto.proto`,例如接口的定义有: -%% Socket & Connection process terminated -terminated(conn(), reason(), state()) -> ok. +```protobuff +syntax = "proto3"; --opaue conn() :: pid(). +package emqx.exproto.v1; --type conninfo() :: [ {socktype, tcp | tls | udp | dtls}, - , {peername, {inet:ip_address(), inet:port_number()}}, - , {sockname, {inet:ip_address(), inet:port_number()}}, - , {peercert, nossl | [{cn, string()}, {dn, string()}]} - ]). +// The Broker side serivce. It provides a set of APIs to +// handle a protcol access +service ConnectionAdapter { --type reason() :: string(). + // -- socket layer --type state() :: any(). + rpc Send(SendBytesRequest) returns (CodeResponse) {}; + + rpc Close(CloseSocketRequest) returns (CodeResponse) {}; + + // -- protocol layer + + rpc Authenticate(AuthenticateRequest) returns (CodeResponse) {}; + + rpc StartTimer(TimerRequest) returns (CodeResponse) {}; + + // -- pub/sub layer + + rpc Publish(PublishRequest) returns (CodeResponse) {}; + + rpc Subscribe(SubscribeRequest) returns (CodeResponse) {}; + + rpc Unsubscribe(UnsubscribeRequest) returns (CodeResponse) {}; +} + +service ConnectionHandler { + + // -- socket layer + + rpc OnSocketCreated(SocketCreatedRequest) returns (EmptySuccess) {}; + + rpc OnSocketClosed(SocketClosedRequest) returns (EmptySuccess) {}; + + rpc OnReceivedBytes(ReceivedBytesRequest) returns (EmptySuccess) {}; + + // -- pub/sub layer + + rpc OnTimerTimeout(TimerTimeoutRequest) returns (EmptySuccess) {}; + + rpc OnReceivedMessages(ReceivedMessagesRequest) returns (EmptySuccess) {}; +} ``` - -`emqx-exproto` 需要向多语言插件提供的接口: - -``` erlang -%% Send a data to socket -send(conn(), data()) -> ok. - -%% Close the socket -close(conn() ) -> ok. -``` - - -### 协议/会话层接口 - -多语言组件需要向 EMQ X 注册的回调函数: - -```erlang -%% Received a message from a Topic -deliver(conn(), [message()], state()) -> state(). - --type message() :: [ {id, binary()} - , {qos, integer()} - , {from, binary()} - , {topic, binary()} - , {payload, binary()} - , {timestamp, integer()} - ]. -``` - - -`emqx-exproto` 需要向多语言插件提供的接口: - -``` erlang -%% Reigster the client to Broker -register(conn(), clientinfo()) -> ok | {error, Reason}. - -%% Publish a message to Broker -publish(conn(), message()) -> ok. - -%% Subscribe a topic -subscribe(conn(), topic(), qos()) -> ok. - -%% Unsubscribe a topic -unsubscribe(conn(), topic()) -> ok. - --type clientinfo() :: [ {proto_name, binary()} - , {proto_ver, integer() | string()} - , {clientid, binary()} - , {username, binary()} - , {mountpoint, binary()}} - , {keepalive, non_neg_integer()} - ]. -``` - -### 管理&统计相关接口 - -*TODO..* - ## 配置项设计 1. 以 **监听器( Listener)** 为基础,提供 TCP/UDP 的监听。 - Listener 目前仅支持:TCP、TLS、UDP、DTLS。(ws、wss、quic 暂不支持) -2. 每个监听器,会指定一个多语言的驱动,用于调用外部模块的接口 - - Driver 目前仅支持:python,java +2. 每个监听器,会指定一个 `ProtocolHandler` 的服务地址,用于调用外部模块的接口。 +3. emqx-exproto 还会监听一个 gRPC 端口用于提供对 `ConnectionAdapter` 服务的访问。 例如: ``` properties -## A JT/T 808 TCP based example: -exproto.listener.jtt808 = 6799 -exproto.listener.jtt808.type = tcp -exproto.listener.jtt808.driver = python -# acceptors, max_connections, max_conn_rate, ... -# proxy_protocol, ... -# sndbuff, recbuff, ... -# ssl, cipher, certfile, psk, ... +## gRPC 服务监听地址 (HTTP) +## +exproto.server.http.url = http://127.0.0.1:9002 -exproto.listener.jtt808. = +## gRPC 服务监听地址 (HTTPS) +## +exproto.server.https.url = https://127.0.0.1:9002 +exproto.server.https.cacertfile = ca.pem +exproto.server.https.certfile = cert.pem +exproto.server.https.keyfile = key.pem + +## Listener 配置 +## 例如,名称为 protoname 协议的 TCP 监听器配置 +exproto.listener.protoname = tcp://0.0.0.0:7993 + +## ProtocolHandler 服务地址及 https 的证书配置 +exproto.listener.protoname.proto_handler_url = http://127.0.0.1:9001 +#exproto.listener.protoname.proto_handler_certfile = +#exproto.listener.protoname.proto_handler_cacertfile = +#exproto.listener.protoname.proto_handler_keyfile = -## A CoAP UDP based example -exproto.listener.coap = 6799 -exproto.listener.coap.type = udp -exproto.listener.coap.driver = java # ... ``` - -## 集成与调试 - -参见 SDK 规范、和对应语言的开发手册 - -## SDK 实现要求 - -参见 SDK 规范、和对应语言的开发手册 - -## TODOs: - -- 认证 和 发布 订阅鉴权等钩子接入 diff --git a/apps/emqx_exproto/docs/images/exproto-arch.jpg b/apps/emqx_exproto/docs/images/exproto-arch.jpg index 54cd63f61..dddf7996b 100644 Binary files a/apps/emqx_exproto/docs/images/exproto-arch.jpg and b/apps/emqx_exproto/docs/images/exproto-arch.jpg differ diff --git a/apps/emqx_exproto/docs/images/exproto-grpc-arch.jpg b/apps/emqx_exproto/docs/images/exproto-grpc-arch.jpg new file mode 100644 index 000000000..71efa76f9 Binary files /dev/null and b/apps/emqx_exproto/docs/images/exproto-grpc-arch.jpg differ diff --git a/apps/emqx_exproto/docs/sdk-specification.md b/apps/emqx_exproto/docs/sdk-specification.md deleted file mode 100644 index 5925259a8..000000000 --- a/apps/emqx_exproto/docs/sdk-specification.md +++ /dev/null @@ -1,84 +0,0 @@ -## SDK 规范 - -### 动机 - -SDK 的目的在于方便用户使用 IDE 集成开发、和模拟调试。 - -### 位置 - -``` - +------------------+ - | User's Codes | - +------------------+ - | SDK | <==== The SDK Located - +------------------+ - | Raw APIs | - +------------------+ - | Driver | - +==================+ - || - +==================+ - | EMQ X Plugin | - +------------------+ -``` - -因此,SDK 的作用在于封装底层的比较晦涩的数据格式和方法,屏蔽驱动的细节。直接提供优化后的 API 供用户使用。 - - -### 实现要求 - -**声明:** stdin, stdout 已用于和 EMQ X 通信,请不要使用。stderr 用于日志输出。 - -#### 基础项 - -1. 必须实现 `emqx-exproto` 要求的回调函数和 API 接口,并能够暴露给用户使用 -2. 可以将 `conn()` 类型,封装成为一个连接类。并: - - 将各层的回调函数写为不同的 `Interface`,连接类实现该 `Interface`,并强制子类实现其方法。 - - 将各层的 API 接口写为连接类的方法 - 用户继承该连接类,并实现各个回调。 - -3. 必须将各个专有的,晦涩的数据类型封装为清晰的类型结构,例如: - - 连接类型 `conn()` - - 连接层信息:conninfo() - - 客户端信息:clientinfo() - - 消息:message() -3. 必须要有对应的开发、部署文档说明 - -#### 高级项 - -1. 应能方便用户能在 IDE 中进行编译,开发 -2. 应提供集成测试用的模拟代码。 - - 例如,生成模拟的数据,发送至用户的程序,方便直接断点调试,而不需要先部署才能使用。 -3. 提供日志输出的方法 - -### 部署结构 - -#### 代码依赖结构 - -从部署的角度看,代码的依赖关系为: - -1. 用户代码: - * 一定会依赖 SDK - * 允许依赖 某个位置的三方/系统库 -2. SDK 代码: - * 只能依赖 erlport - -#### 部署 - -从文件存放的位置来看,一个标准的部署结构为: - -``` -emqx -| -|--- data -|------- extension -|---------- -|--------------- -|---------- -| -|---------- -|--------------- -|---------- -``` - -它表达了:在 `data/extension` 目录下安装了两个 SDK,并且用户都基于 SDK 编写了其回调的代码模块。 diff --git a/apps/emqx_exproto/etc/emqx_exproto.conf b/apps/emqx_exproto/etc/emqx_exproto.conf index d3332d24f..a64153791 100644 --- a/apps/emqx_exproto/etc/emqx_exproto.conf +++ b/apps/emqx_exproto/etc/emqx_exproto.conf @@ -2,6 +2,13 @@ ## EMQ X ExProto ##==================================================================== +exproto.server.http.port = 9100 + +exproto.server.https.port = 9101 +exproto.server.https.cacertfile = {{ platform_etc_dir }}/certs/cacert.pem +exproto.server.https.certfile = {{ platform_etc_dir }}/certs/cert.pem +exproto.server.https.keyfile = {{ platform_etc_dir }}/certs/key.pem + ##-------------------------------------------------------------------- ## Listeners ##-------------------------------------------------------------------- @@ -16,18 +23,13 @@ ## Examples: tcp://0.0.0.0:7993 | ssl://127.0.0.1:7994 exproto.listener.protoname = tcp://0.0.0.0:7993 -## Driver type +## The ConnectionHandler server address ## -## Value: python3 | java -exproto.listener.protoname.driver = python3 +exproto.listener.protoname.connection_handler_url = http://127.0.0.1:9001 -## The Search path for driver codes -## -exproto.listener.protoname.driver_search_path = {{ platform_data_dir }}/extension - -## The driver callback module/class name -## -#exproto.listener.protoname.driver_callback_module = main +#exproto.listener.protoname.connection_handler_certfile = +#exproto.listener.protoname.connection_handler_cacertfile = +#exproto.listener.protoname.connection_handler_keyfile = ## The acceptor pool for external MQTT/TCP listener. ## diff --git a/apps/emqx_exproto/example/Main.java b/apps/emqx_exproto/example/Main.java deleted file mode 100644 index 811bab135..000000000 --- a/apps/emqx_exproto/example/Main.java +++ /dev/null @@ -1,136 +0,0 @@ -import java.io.*; -import java.util.*; -import com.erlport.erlang.term.*; -import com.erlport.*; - -class State implements Serializable { - - Integer times; - - public State() { - times = 0; - } - - public Integer incr() { - times += 1; - return times; - } - - @Override - public String toString() { - return String.format("State(times: %d)", times); - } -} - -public class Main { - - static Integer OK = 0; - static Integer ERROR = 0; - - //------------------- - // Connection level - - public static Object init(Object conn, Object connInfo) { - System.err.printf("[java] established a conn=%s, connInfo=%s\n", conn, connInfo); - - // set an instance to be the connection state - // it just a example structure to record the callback total times - Object state = new State(); - - // subscribe the topic `t/dn` with qos0 - subscribe(conn, new Binary("t/dn"), 0); - - // return the initial conn's state - return Tuple.two(OK, state); - } - - public static Object received(Object conn, Object data, Object state) { - System.err.printf("[java] received data conn=%s, data=%s, state=%s\n", conn, data, state); - - // echo the conn's data - send(conn, data); - - // return the new conn's state - State nstate = (State) state; - nstate.incr(); - return Tuple.two(OK, nstate); - } - - public static void terminated(Object conn, Object reason, Object state) { - System.err.printf("[java] terminated conn=%s, reason=%s, state=%s\n", conn, reason, state); - return; - } - - //----------------------- - // Protocol/Session level - - public static Object deliver(Object conn, Object msgs0, Object state) { - System.err.printf("[java] received messages conn=%s, msgs=%s, state=%s\n", conn, msgs0, state); - - List msgs = (List) msgs0; - for(Object msg: msgs) { - publish(conn, msg); - } - - // return the new conn's state - State nstate = (State) state; - nstate.incr(); - return Tuple.two(OK, nstate); - } - - //----------------------- - // APIs - public static void send(Object conn, Object data) { - try { - Erlang.call("emqx_exproto", "send", new Object[]{conn, data}, 5000); - } catch (Exception e) { - System.err.printf("[java] send data error: %s\n", e); - } - return; - } - - public static void close(Object conn) { - try { - Erlang.call("emqx_exproto", "close", new Object[]{conn}, 5000); - } catch (Exception e) { - System.err.printf("[java] send data error: %s\n", e); - } - return; - } - - public static void register(Object conn, Object clientInfo) { - try { - Erlang.call("emqx_exproto", "register", new Object[]{conn, clientInfo}, 5000); - } catch (Exception e) { - System.err.printf("[java] send data error: %s\n", e); - } - return; - } - - public static void publish(Object conn, Object message) { - try { - Erlang.call("emqx_exproto", "publish", new Object[]{conn, message}, 5000); - } catch (Exception e) { - System.err.printf("[java] send data error: %s\n", e); - } - return; - } - - public static void subscribe(Object conn, Object topic, Object qos) { - try { - Erlang.call("emqx_exproto", "subscribe", new Object[]{conn, topic, qos}, 5000); - } catch (Exception e) { - System.err.printf("[java] send data error: %s\n", e); - } - return; - } - - public static void unsubscribe(Object conn, Object topic) { - try { - Erlang.call("emqx_exproto", "unsubscribe", new Object[]{conn, topic}, 5000); - } catch (Exception e) { - System.err.printf("[java] send data error: %s\n", e); - } - return; - } -} diff --git a/apps/emqx_exproto/example/main.py b/apps/emqx_exproto/example/main.py deleted file mode 100644 index 1e630329e..000000000 --- a/apps/emqx_exproto/example/main.py +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/python -# -*- coding: UTF-8 -*- - -from erlport import Atom -from erlport import erlang - -OK = 0 -ERROR = 1 - -##-------------------------------------------------------------------- -## Connection level - -def init(conn, conninfo): - print(f'[python] established a conn={conn}, conninfo={conninfo}') - - ## set an integer num to the connection state - ## it just a example structure to record the callback total times - state = 0 - - ## subscribe the topic `t/dn` with qos0 - subscribe(conn, b"t/dn", 0) - - ## return the initial conn's state - return (OK, state) - -def received(conn, data, state): - print(f'[python] received data conn={conn}, data={data}, state={state}') - - ## echo the conn's data - send(conn, data) - - ## return the new conn's state - return (OK, state+1) - -def terminated(conn, reason, state): - print(f'[python] terminated conn={conn}, reason={reason}, state={state}') - return - -##-------------------------------------------------------------------- -## Protocol/Session level - -def deliver(conn, msgs, state): - print(f'[python] received messages: conn={conn}, msgs={msgs}, state={state}') - - ## echo the protocol/session messages - for msg in msgs: - msg[3] = (Atom(b'topic'), b't/up') - publish(conn, msg) - - ## return the new conn's state - return (OK, state+1) - -##-------------------------------------------------------------------- -## APIs -##-------------------------------------------------------------------- - -def send(conn, data): - erlang.call(Atom(b'emqx_exproto'), Atom(b'send'), [conn, data]) - return - -def close(conn): - erlang.call(Atom(b'emqx_exproto'), Atom(b'close'), [conn]) - return - -def register(conn, clientinfo): - erlang.call(Atom(b'emqx_exproto'), Atom(b'register'), [conn, clientinfo]) - return - -def publish(conn, message): - erlang.call(Atom(b'emqx_exproto'), Atom(b'publish'), [conn, message]) - return - -def subscribe(conn, topic, qos): - erlang.call(Atom(b'emqx_exproto'), Atom(b'subscribe'), [conn, topic, qos]) - return - -def unsubscribe(conn, topic): - erlang.call(Atom(b'emqx_exproto'), Atom(b'subscribe'), [conn, topic]) - return - diff --git a/apps/emqx_exproto/include/emqx_exproto.hrl b/apps/emqx_exproto/include/emqx_exproto.hrl index 7dcb377f8..079a1e60f 100644 --- a/apps/emqx_exproto/include/emqx_exproto.hrl +++ b/apps/emqx_exproto/include/emqx_exproto.hrl @@ -22,3 +22,16 @@ %% TODO: -define(UDP_SOCKOPTS, []). +%%-------------------------------------------------------------------- +%% gRPC result code + +-define(RESP_UNKNOWN, 'UNKNOWN'). +-define(RESP_SUCCESS, 'SUCCESS'). +-define(RESP_CONN_PROCESS_NOT_ALIVE, 'CONN_PROCESS_NOT_ALIVE'). +-define(RESP_PARAMS_TYPE_ERROR, 'PARAMS_TYPE_ERROR'). +-define(RESP_REQUIRED_PARAMS_MISSED, 'REQUIRED_PARAMS_MISSED'). +-define(RESP_PERMISSION_DENY, 'PERMISSION_DENY'). +-define(IS_GRPC_RESULT_CODE(C), ( C =:= ?RESP_SUCCESS + orelse C =:= ?RESP_CONN_PROCESS_NOT_ALIVE + orelse C =:= ?RESP_REQUIRED_PARAMS_MISSED + orelse C =:= ?RESP_PERMISSION_DENY)). diff --git a/apps/emqx_exproto/priv/emqx_exproto.schema b/apps/emqx_exproto/priv/emqx_exproto.schema index af63f56f9..fb114dc77 100644 --- a/apps/emqx_exproto/priv/emqx_exproto.schema +++ b/apps/emqx_exproto/priv/emqx_exproto.schema @@ -1,25 +1,66 @@ %% -*-: erlang -*- -%%-------------------------------------------------------------------- -%% Listeners -%%-------------------------------------------------------------------- %%-------------------------------------------------------------------- -%% TCP Listeners +%% Services + +{mapping, "exproto.server.http.port", "emqx_exproto.servers", [ + {datatype, integer} +]}. + +{mapping, "exproto.server.https.port", "emqx_exproto.servers", [ + {datatype, integer} +]}. + +{mapping, "exproto.server.https.cacertfile", "emqx_exproto.servers", [ + {datatype, string} +]}. + +{mapping, "exproto.server.https.certfile", "emqx_exproto.servers", [ + {datatype, string} +]}. + +{mapping, "exproto.server.https.keyfile", "emqx_exproto.servers", [ + {datatype, string} +]}. + +{translation, "emqx_exproto.servers", fun(Conf) -> + Filter = fun(Opts) -> [{K, V} || {K, V} <- Opts, V =/= undefined] end, + Http = case cuttlefish:conf_get("exproto.server.http.port", Conf, undefined) of + undefined -> []; + P1 -> [{http, P1, []}] + end, + Https = case cuttlefish:conf_get("exproto.server.https.port", Conf, undefined) of + undefined -> []; + P2 -> + [{https, P2, + Filter([{ssl, true}, + {certfile, cuttlefish:conf_get("exproto.server.https.certfile", Conf)}, + {keyfile, cuttlefish:conf_get("exproto.server.https.keyfile", Conf)}, + {cacertfile, cuttlefish:conf_get("exproto.server.https.cacertfile", Conf)}])}] + end, + Http ++ Https +end}. + +%%-------------------------------------------------------------------- +%% Listeners {mapping, "exproto.listener.$proto", "emqx_exproto.listeners", [ {datatype, string} ]}. -{mapping, "exproto.listener.$proto.driver", "emqx_exproto.listeners", [ - {datatype, {enum, [python3, java]}} -]}. - -{mapping, "exproto.listener.$proto.driver_search_path", "emqx_exproto.listeners", [ +{mapping, "exproto.listener.$proto.connection_handler_url", "emqx_exproto.listeners", [ {datatype, string} ]}. -{mapping, "exproto.listener.$proto.driver_callback_module", "emqx_exproto.listeners", [ - {default, "main"}, +{mapping, "exproto.listener.$proto.connection_handler_certfile", "emqx_exproto.listeners", [ + {datatype, string} +]}. + +{mapping, "exproto.listener.$proto.connection_handler_cacertfile", "emqx_exproto.listeners", [ + {datatype, string} +]}. + +{mapping, "exproto.listener.$proto.connection_handler_keyfile", "emqx_exproto.listeners", [ {datatype, string} ]}. @@ -190,14 +231,23 @@ {Rate, Limit} end, - DriverOpts = fun(Prefix) -> - [{driver, - Filter([{type, cuttlefish:conf_get(Prefix ++ ".driver", Conf)}, - {path, cuttlefish:conf_get(Prefix ++ ".driver_search_path", Conf)}, - {cbm, Atom(cuttlefish:conf_get(Prefix ++ ".driver_callback_module", Conf))} - ]) - }] - end, + HandlerOpts = fun(Prefix) -> + Opts = + case http_uri:parse(cuttlefish:conf_get(Prefix ++ ".connection_handler_url", Conf)) of + {ok, {http, _, Host, Port, _, _}} -> + [{scheme, http}, {host, Host}, {port, Port}]; + {ok, {https, _, Host, Port, _, _}} -> + [{scheme, https}, {host, Host}, {port, Port}, + {ssl_options, + Filter([{certfile, cuttlefish:conf_get(Prefix ++ ".connection_handler_certfile", Conf)}, + {keyfile, cuttlefish:conf_get(Prefix ++ ".connection_handler_keyfile", Conf)}, + {cacertfile, cuttlefish:conf_get(Prefix ++ ".connection_handler_cacertfile", Conf)} + ])}]; + _ -> + error(invaild_connection_handler_url) + end, + [{handler, Opts}] + end, ConnOpts = fun(Prefix) -> Filter([{active_n, cuttlefish:conf_get(Prefix ++ ".active_n", Conf, undefined)}, @@ -289,7 +339,7 @@ Listeners = fun(Proto) -> Prefix = string:join(["exproto","listener", Proto], "."), - Opts = DriverOpts(Prefix) ++ ConnOpts(Prefix) ++ LisOpts(Prefix), + Opts = HandlerOpts(Prefix) ++ ConnOpts(Prefix) ++ LisOpts(Prefix), case cuttlefish:conf_get(Prefix, Conf, undefined) of undefined -> []; ListenOn0 -> diff --git a/apps/emqx_exproto/priv/protos/exproto.proto b/apps/emqx_exproto/priv/protos/exproto.proto new file mode 100644 index 000000000..633cc1758 --- /dev/null +++ b/apps/emqx_exproto/priv/protos/exproto.proto @@ -0,0 +1,259 @@ +//------------------------------------------------------------------------------ +// Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//------------------------------------------------------------------------------ + +syntax = "proto3"; + +package emqx.exproto.v1; + +// The Broker side serivce. It provides a set of APIs to +// handle a protcol access +service ConnectionAdapter { + + // -- socket layer + + rpc Send(SendBytesRequest) returns (CodeResponse) {}; + + rpc Close(CloseSocketRequest) returns (CodeResponse) {}; + + // -- protocol layer + + rpc Authenticate(AuthenticateRequest) returns (CodeResponse) {}; + + rpc StartTimer(TimerRequest) returns (CodeResponse) {}; + + // -- pub/sub layer + + rpc Publish(PublishRequest) returns (CodeResponse) {}; + + rpc Subscribe(SubscribeRequest) returns (CodeResponse) {}; + + rpc Unsubscribe(UnsubscribeRequest) returns (CodeResponse) {}; +} + +service ConnectionHandler { + + // -- socket layer + + rpc OnSocketCreated(SocketCreatedRequest) returns (EmptySuccess) {}; + + rpc OnSocketClosed(SocketClosedRequest) returns (EmptySuccess) {}; + + rpc OnReceivedBytes(ReceivedBytesRequest) returns (EmptySuccess) {}; + + // -- pub/sub layer + + rpc OnTimerTimeout(TimerTimeoutRequest) returns (EmptySuccess) {}; + + rpc OnReceivedMessages(ReceivedMessagesRequest) returns (EmptySuccess) {}; +} + +message EmptySuccess { } + +enum ResultCode { + + // Operation successfully + SUCCESS = 0; + + // Unknown Error + UNKNOWN = 1; + + // Connection process is not alive + CONN_PROCESS_NOT_ALIVE = 2; + + // Miss the required parameter + REQUIRED_PARAMS_MISSED = 3; + + // Params type or values incorrect + PARAMS_TYPE_ERROR = 4; + + // No permission or Pre-conditions not fulfilled + PERMISSION_DENY = 5; +} + +message CodeResponse { + + ResultCode code = 1; + + // The reason message if result is false + string message = 2; +} + +message SendBytesRequest { + + string conn = 1; + + bytes bytes = 2; +} + +message CloseSocketRequest { + + string conn = 1; +} + +message AuthenticateRequest { + + string conn = 1; + + ClientInfo clientinfo = 2; + + string password = 3; +} + +message TimerRequest { + + string conn = 1; + + TimerType type = 2; + + uint32 interval = 3; +} + +enum TimerType { + + KEEPALIVE = 0; +} + +message PublishRequest { + + string conn = 1; + + string topic = 2; + + uint32 qos = 3; + + bytes payload = 4; +} + +message SubscribeRequest { + + string conn = 1; + + string topic = 2; + + uint32 qos = 3; +} + +message UnsubscribeRequest { + + string conn = 1; + + string topic = 2; +} + +message SocketCreatedRequest { + + string conn = 1; + + ConnInfo conninfo = 2; +} + +message ReceivedBytesRequest { + + string conn = 1; + + bytes bytes = 2; +} + +message TimerTimeoutRequest { + + string conn = 1; + + TimerType type = 2; +} + +message SocketClosedRequest { + + string conn = 1; + + string reason = 2; +} + +message ReceivedMessagesRequest { + + string conn = 1; + + repeated Message messages = 2; +} + +//-------------------------------------------------------------------- +// Basic data types +//-------------------------------------------------------------------- + +message ConnInfo { + + SocketType socktype = 1; + + Address peername = 2; + + Address sockname = 3; + + CertificateInfo peercert = 4; +} + +enum SocketType { + + TCP = 0; + + SSL = 1; + + UDP = 2; + + DTLS = 3; +} + +message Address { + + string host = 1; + + uint32 port = 2; +} + +message CertificateInfo { + + string cn = 1; + + string dn = 2; +} + +message ClientInfo { + + string proto_name = 1; + + string proto_ver = 2; + + string clientid = 3; + + string username = 4; + + string mountpoint = 5; +} + +message Message { + + string node = 1; + + string id = 2; + + uint32 qos = 3; + + string from = 4; + + string topic = 5; + + bytes payload = 6; + + uint64 timestamp = 7; +} diff --git a/apps/emqx_exproto/rebar.config b/apps/emqx_exproto/rebar.config index 52225fea3..9dd5a2090 100644 --- a/apps/emqx_exproto/rebar.config +++ b/apps/emqx_exproto/rebar.config @@ -1,7 +1,4 @@ %%-*- mode: erlang -*- - -{deps, [{erlport, {git, "https://github.com/emqx/erlport", {tag, "v1.2.2"}}}]}. - {edoc_opts, [{preprocess, true}]}. {erl_opts, [warn_unused_vars, @@ -10,18 +7,44 @@ warn_obsolete_guard, debug_info, {parse_transform}]}. +{plugins, + [rebar3_proper, + {grpc_plugin, {git, "https://github.com/HJianBo/grpcbox_plugin", {tag, "v0.9.1"}}} +]}. + +{deps, + [{grpc, {git, "https://github.com/emqx/grpc", {tag, "0.5.0"}}} + ]}. + +{grpc, + [{type, all}, + {protos, ["priv/protos"]}, + {gpb_opts, [{module_name_prefix, "emqx_"}, + {module_name_suffix, "_pb"}]} + ]}. + +{provider_hooks, + [{pre, [{compile, {grpc, gen}}]}]}. {xref_checks, [undefined_function_calls, undefined_functions, locals_not_used, deprecated_function_calls, warnings_as_errors, deprecated_functions]}. + +{xref_ignores, [emqx_exproto_pb]}. + {cover_enabled, true}. {cover_opts, [verbose]}. {cover_export_enabled, true}. +{cover_excl_mods, [emqx_exproto_pb, + emqx_exproto_v_1_connection_adapter_client, + emqx_exproto_v_1_connection_adapter_bhvr, + emqx_exproto_v_1_connection_handler_client, + emqx_exproto_v_1_connection_handler_bhvr]}. {profiles, - [{test, [ - {deps, [ {emqx_ct_helper, {git, "https://github.com/emqx/emqx-ct-helpers", {tag, "v1.3.0"}}} - , {cuttlefish, {git, "https://github.com/emqx/cuttlefish", {tag, "v3.0.0"}}} - ]} - ]} + [{test, + [{deps, + [{emqx_ct_helper, {git, "https://github.com/emqx/emqx-ct-helpers", {tag, "v1.3.0"}}} + ]} + ]} ]}. diff --git a/apps/emqx_exproto/sdk/README.md b/apps/emqx_exproto/sdk/README.md deleted file mode 100644 index 444601a76..000000000 --- a/apps/emqx_exproto/sdk/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# SDKs - -A specific language SDK is a suite of codes for user-oriented friendly. - -Even it does not need it for you to develop the Multiple language support plugins, but it provides more friendly APIs and Abstract for you - - -Now, we provide the following SDKs: - -- Java: https://github.com/emqx/emqx-exproto-java-sdk -- Python: https://github.com/emqx/emqx-exproto-python-sdk diff --git a/apps/emqx_exproto/src/emqx_exproto.app.src b/apps/emqx_exproto/src/emqx_exproto.app.src index 37869ecc1..282397dc4 100644 --- a/apps/emqx_exproto/src/emqx_exproto.app.src +++ b/apps/emqx_exproto/src/emqx_exproto.app.src @@ -4,11 +4,9 @@ {modules, []}, {registered, []}, {mod, {emqx_exproto_app, []}}, - {applications, [kernel, stdlib, erlport]}, + {applications, [kernel,stdlib,grpc]}, {env,[]}, {licenses, ["Apache-2.0"]}, {maintainers, ["EMQ X Team "]}, - {links, [{"Homepage", "https://emqx.io/"}, - {"Github", "https://github.com/emqx/emqx-extension-proto"} - ]} + {links, [{"Homepage", "https://emqx.io/"}]} ]}. diff --git a/apps/emqx_exproto/src/emqx_exproto.app.src.script b/apps/emqx_exproto/src/emqx_exproto.app.src.script new file mode 100644 index 000000000..b549f9e39 --- /dev/null +++ b/apps/emqx_exproto/src/emqx_exproto.app.src.script @@ -0,0 +1,24 @@ +%%-*- mode: erlang -*- +%% .app.src.script + +RemoveLeadingV = + fun(Tag) -> + case re:run(Tag, "^[v]?[0-9]\.[0-9]\.([0-9]|(rc|beta|alpha)\.[0-9])", [{capture, none}]) of + nomatch -> + re:replace(Tag, "/", "-", [{return ,list}]); + _ -> + %% if it is a version number prefixed by 'v' or 'e', then remove it + re:replace(Tag, "[v]", "", [{return ,list}]) + end + end, + +case os:getenv("EMQX_DEPS_DEFAULT_VSN") of + false -> CONFIG; % env var not defined + [] -> CONFIG; % env var set to empty string + Tag -> + [begin + AppConf0 = lists:keystore(vsn, 1, AppConf, {vsn, RemoveLeadingV(Tag)}), + {application, App, AppConf0} + end || Conf = {application, App, AppConf} <- CONFIG] +end. + diff --git a/apps/emqx_exproto/src/emqx_exproto.appup.src b/apps/emqx_exproto/src/emqx_exproto.appup.src new file mode 100644 index 000000000..dcf0d8cdd --- /dev/null +++ b/apps/emqx_exproto/src/emqx_exproto.appup.src @@ -0,0 +1,9 @@ +%% -*-: erlang -*- +{VSN, + [ + {<<".*">>, []} + ], + [ + {<<".*">>, []} + ] +}. diff --git a/apps/emqx_exproto/src/emqx_exproto.erl b/apps/emqx_exproto/src/emqx_exproto.erl index c8c36b19e..7d986ecdd 100644 --- a/apps/emqx_exproto/src/emqx_exproto.erl +++ b/apps/emqx_exproto/src/emqx_exproto.erl @@ -16,24 +16,20 @@ -module(emqx_exproto). --compile({no_auto_import, [register/1]}). - -include("emqx_exproto.hrl"). -export([ start_listeners/0 , stop_listeners/0 + , start_listener/1 + , start_listener/4 + , stop_listener/4 + , stop_listener/1 ]). -%% APIs: Connection level --export([ send/2 - , close/1 - ]). - -%% APIs: Protocol/Session level --export([ register/2 - , publish/2 - , subscribe/3 - , unsubscribe/2 +-export([ start_servers/0 + , stop_servers/0 + , start_server/1 + , stop_server/1 ]). %%-------------------------------------------------------------------- @@ -42,78 +38,71 @@ -spec(start_listeners() -> ok). start_listeners() -> - lists:foreach(fun start_listener/1, application:get_env(?APP, listeners, [])). + Listeners = application:get_env(?APP, listeners, []), + NListeners = [start_connection_handler_instance(Listener) + || Listener <- Listeners], + lists:foreach(fun start_listener/1, NListeners). -spec(stop_listeners() -> ok). stop_listeners() -> - lists:foreach(fun stop_listener/1, application:get_env(?APP, listeners, [])). + Listeners = application:get_env(?APP, listeners, []), + lists:foreach(fun stop_connection_handler_instance/1, Listeners), + lists:foreach(fun stop_listener/1, Listeners). -%%-------------------------------------------------------------------- -%% APIs - Connection level -%%-------------------------------------------------------------------- +-spec(start_servers() -> ok). +start_servers() -> + lists:foreach(fun start_server/1, application:get_env(?APP, servers, [])). --spec(send(pid(), binary()) -> ok). -send(Conn, Data) when is_pid(Conn), is_binary(Data) -> - emqx_exproto_conn:cast(Conn, {send, Data}). - --spec(close(pid()) -> ok). -close(Conn) when is_pid(Conn) -> - emqx_exproto_conn:cast(Conn, close). - -%%-------------------------------------------------------------------- -%% APIs - Protocol/Session level -%%-------------------------------------------------------------------- - --spec(register(pid(), list()) -> ok | {error, any()}). -register(Conn, ClientInfo0) -> - case emqx_exproto_types:parse(clientinfo, ClientInfo0) of - {error, Reason} -> - {error, Reason}; - ClientInfo -> - emqx_exproto_conn:cast(Conn, {register, ClientInfo}) - end. - --spec(publish(pid(), list()) -> ok | {error, any()}). -publish(Conn, Msg0) when is_pid(Conn), is_list(Msg0) -> - case emqx_exproto_types:parse(message, Msg0) of - {error, Reason} -> - {error, Reason}; - Msg -> - emqx_exproto_conn:cast(Conn, {publish, Msg}) - end. - --spec(subscribe(pid(), binary(), emqx_types:qos()) -> ok | {error, any()}). -subscribe(Conn, Topic, Qos) - when is_pid(Conn), is_binary(Topic), - (Qos =:= 0 orelse Qos =:= 1 orelse Qos =:= 2) -> - emqx_exproto_conn:cast(Conn, {subscribe, Topic, Qos}). - --spec(unsubscribe(pid(), binary()) -> ok | {error, any()}). -unsubscribe(Conn, Topic) - when is_pid(Conn), is_binary(Topic) -> - emqx_exproto_conn:cast(Conn, {unsubscribe, Topic}). +-spec(stop_servers() -> ok). +stop_servers() -> + lists:foreach(fun stop_server/1, application:get_env(?APP, servers, [])). %%-------------------------------------------------------------------- %% Internal functions %%-------------------------------------------------------------------- +start_connection_handler_instance({_Proto, _LisType, _ListenOn, Opts}) -> + Name = name(_Proto, _LisType), + {value, {_, HandlerOpts}, LisOpts} = lists:keytake(handler, 1, Opts), + {SvrAddr, ChannelOptions} = handler_opts(HandlerOpts), + case emqx_exproto_sup:start_grpc_client_channel(Name, SvrAddr, ChannelOptions) of + {ok, _ClientChannelPid} -> + {_Proto, _LisType, _ListenOn, [{handler, Name} | LisOpts]}; + {error, Reason} -> + io:format(standard_error, "Failed to start ~s's connection handler - ~0p~n!", + [Name, Reason]), + error(Reason) + end. + +stop_connection_handler_instance({_Proto, _LisType, _ListenOn, _Opts}) -> + Name = name(_Proto, _LisType), + _ = emqx_exproto_sup:stop_grpc_client_channel(Name), + ok. + +start_server({Name, Port, SSLOptions}) -> + case emqx_exproto_sup:start_grpc_server(Name, Port, SSLOptions) of + {ok, _} -> + io:format("Start ~s gRPC server on ~w successfully.~n", + [Name, Port]); + {error, Reason} -> + io:format(standard_error, "Failed to start ~s gRPC server on ~w - ~0p~n!", + [Name, Port, Reason]), + error({failed_start_server, Reason}) + end. + +stop_server({Name, Port, _SSLOptions}) -> + ok = emqx_exproto_sup:stop_grpc_server(Name), + io:format("Stop ~s gRPC server on ~w successfully.~n", [Name, Port]). + start_listener({Proto, LisType, ListenOn, Opts}) -> Name = name(Proto, LisType), - {value, {_, DriverOpts}, LisOpts} = lists:keytake(driver, 1, Opts), - case emqx_exproto_driver_mngr:ensure_driver(Name, DriverOpts) of - {ok, _DriverPid}-> - case start_listener(LisType, Name, ListenOn, [{driver, Name} |LisOpts]) of - {ok, _} -> - io:format("Start ~s listener on ~s successfully.~n", - [Name, format(ListenOn)]); - {error, Reason} -> - io:format(standard_error, "Failed to start ~s listener on ~s - ~0p~n!", - [Name, format(ListenOn), Reason]), - error(Reason) - end; + case start_listener(LisType, Name, ListenOn, Opts) of + {ok, _} -> + io:format("Start ~s listener on ~s successfully.~n", + [Name, format(ListenOn)]); {error, Reason} -> - io:format(standard_error, "Failed to start ~s's driver - ~0p~n!", - [Name, Reason]), + io:format(standard_error, "Failed to start ~s listener on ~s - ~0p~n!", + [Name, format(ListenOn), Reason]), error(Reason) end. @@ -137,11 +126,11 @@ start_listener(dtls, Name, ListenOn, LisOpts) -> stop_listener({Proto, LisType, ListenOn, Opts}) -> Name = name(Proto, LisType), - _ = emqx_exproto_driver_mngr:stop_driver(Name), StopRet = stop_listener(LisType, Name, ListenOn, Opts), case StopRet of - ok -> io:format("Stop ~s listener on ~s successfully.~n", - [Name, format(ListenOn)]); + ok -> + io:format("Stop ~s listener on ~s successfully.~n", + [Name, format(ListenOn)]); {error, Reason} -> io:format(standard_error, "Failed to stop ~s listener on ~s - ~p~n.", [Name, format(ListenOn), Reason]) @@ -157,8 +146,12 @@ name(Proto, LisType) -> list_to_atom(lists:flatten(io_lib:format("~s:~s", [Proto, LisType]))). %% @private +format(Port) when is_integer(Port) -> + io_lib:format("0.0.0.0:~w", [Port]); format({Addr, Port}) when is_list(Addr) -> - io_lib:format("~s:~w", [Addr, Port]). + io_lib:format("~s:~w", [Addr, Port]); +format({Addr, Port}) when is_tuple(Addr) -> + io_lib:format("~s:~w", [inet:ntoa(Addr), Port]). %% @private merge_tcp_default(Opts) -> @@ -176,3 +169,19 @@ merge_udp_default(Opts) -> false -> [{udp_options, ?UDP_SOCKOPTS} | Opts] end. + +%% @private +handler_opts(Opts) -> + Scheme = proplists:get_value(scheme, Opts), + Host = proplists:get_value(host, Opts), + Port = proplists:get_value(port, Opts), + SvrAddr = lists:flatten(io_lib:format("~s://~s:~w", [Scheme, Host, Port])), + ClientOpts = case Scheme of + https -> + SslOpts = lists:keydelete(ssl, 1, proplists:get_value(ssl_options, Opts, [])), + #{gun_opts => + #{transport => ssl, + transport_opts => SslOpts}}; + _ -> #{} + end, + {SvrAddr, ClientOpts}. diff --git a/apps/emqx_exproto/src/emqx_exproto_app.erl b/apps/emqx_exproto/src/emqx_exproto_app.erl index b12101fda..73e8a65bc 100644 --- a/apps/emqx_exproto/src/emqx_exproto_app.erl +++ b/apps/emqx_exproto/src/emqx_exproto_app.erl @@ -24,13 +24,14 @@ start(_StartType, _StartArgs) -> {ok, Sup} = emqx_exproto_sup:start_link(), + emqx_exproto:start_servers(), emqx_exproto:start_listeners(), {ok, Sup}. prep_stop(State) -> + emqx_exproto:stop_servers(), emqx_exproto:stop_listeners(), State. stop(_State) -> ok. - diff --git a/apps/emqx_exproto/src/emqx_exproto_channel.erl b/apps/emqx_exproto/src/emqx_exproto_channel.erl index 5a8e2c888..9786c12c2 100644 --- a/apps/emqx_exproto/src/emqx_exproto_channel.erl +++ b/apps/emqx_exproto/src/emqx_exproto_channel.erl @@ -16,6 +16,7 @@ -module(emqx_exproto_channel). +-include("emqx_exproto.hrl"). -include_lib("emqx/include/emqx.hrl"). -include_lib("emqx/include/emqx_mqtt.hrl"). -include_lib("emqx/include/types.hrl"). @@ -41,20 +42,26 @@ -export_type([channel/0]). -record(channel, { - %% Driver name - driver :: atom(), + %% gRPC channel options + gcli :: map(), %% Conn info conninfo :: emqx_types:conninfo(), %% Client info from `register` function clientinfo :: maybe(map()), - %% Registered - registered = false :: boolean(), %% Connection state conn_state :: conn_state(), %% Subscription subscriptions = #{}, - %% Driver level state - state :: any() + %% Request queue + rqueue = queue:new(), + %% Inflight function name + inflight = undefined, + %% Keepalive + keepalive :: maybe(emqx_keepalive:keepalive()), + %% Timers + timers :: #{atom() => disabled | maybe(reference())}, + %% Closed reason + closed_reason = undefined }). -opaque(channel() :: #channel{}). @@ -67,6 +74,11 @@ -type(replies() :: emqx_types:packet() | reply() | [reply()]). +-define(TIMER_TABLE, #{ + alive_timer => keepalive, + force_timer => force_close + }). + -define(INFO_KEYS, [conninfo, conn_state, clientinfo, session, will_msg]). -define(SESSION_STATS_KEYS, @@ -130,20 +142,44 @@ stats(#channel{subscriptions = Subs}) -> %%-------------------------------------------------------------------- -spec(init(emqx_exproto_types:conninfo(), proplists:proplist()) -> channel()). -init(ConnInfo, Options) -> - Driver = proplists:get_value(driver, Options), - case cb_init(ConnInfo, Driver) of - {ok, DState} -> - NConnInfo = default_conninfo(ConnInfo), - ClientInfo = default_clientinfo(ConnInfo), - #channel{driver = Driver, - state = DState, - conninfo = NConnInfo, - clientinfo = ClientInfo, - conn_state = connected}; - {error, Reason} -> - exit({init_channel_failed, Reason}) - end. +init(ConnInfo = #{socktype := Socktype, + peername := Peername, + sockname := Sockname, + peercert := Peercert}, Options) -> + GRpcChann = proplists:get_value(handler, Options), + NConnInfo = default_conninfo(ConnInfo), + ClientInfo = default_clientinfo(ConnInfo), + Channel = #channel{gcli = #{channel => GRpcChann}, + conninfo = NConnInfo, + clientinfo = ClientInfo, + conn_state = connecting, + timers = #{} + }, + + Req = #{conninfo => + peercert(Peercert, + #{socktype => socktype(Socktype), + peername => address(Peername), + sockname => address(Sockname)})}, + try_dispatch(on_socket_created, wrap(Req), Channel). + +%% @private +peercert(nossl, ConnInfo) -> + ConnInfo; +peercert(Peercert, ConnInfo) -> + ConnInfo#{peercert => + #{cn => esockd_peercert:common_name(Peercert), + dn => esockd_peercert:subject(Peercert)}}. + +%% @private +socktype(tcp) -> 'TCP'; +socktype(ssl) -> 'SSL'; +socktype(udp) -> 'UDP'; +socktype(dtls) -> 'DTLS'. + +%% @private +address({Host, Port}) -> + #{host => inet:ntoa(Host), port => Port}. %%-------------------------------------------------------------------- %% Handle incoming packet @@ -153,81 +189,163 @@ init(ConnInfo, Options) -> -> {ok, channel()} | {shutdown, Reason :: term(), channel()}). handle_in(Data, Channel) -> - case cb_received(Data, Channel) of - {ok, NChannel} -> - {ok, NChannel}; - {error, Reason} -> - {shutdown, Reason, Channel} - end. + Req = #{bytes => Data}, + {ok, try_dispatch(on_received_bytes, wrap(Req), Channel)}. -spec(handle_deliver(list(emqx_types:deliver()), channel()) -> {ok, channel()} | {shutdown, Reason :: term(), channel()}). -handle_deliver(Delivers, Channel) -> - %% TODO: ?? Nack delivers from shared subscriptions - case cb_deliver(Delivers, Channel) of - {ok, NChannel} -> - {ok, NChannel}; - {error, Reason} -> - {shutdown, Reason, Channel} - end. +handle_deliver(Delivers, Channel = #channel{clientinfo = ClientInfo}) -> + %% XXX: ?? Nack delivers from shared subscriptions + Mountpoint = maps:get(mountpoint, ClientInfo), + NodeStr = atom_to_binary(node(), utf8), + Msgs = lists:map(fun({_, _, Msg}) -> + ok = emqx_metrics:inc('messages.delivered'), + Msg1 = emqx_hooks:run_fold('message.delivered', + [ClientInfo], Msg), + NMsg = emqx_mountpoint:unmount(Mountpoint, Msg1), + #{node => NodeStr, + id => hexstr(emqx_message:id(NMsg)), + qos => emqx_message:qos(NMsg), + from => fmt_from(emqx_message:from(NMsg)), + topic => emqx_message:topic(NMsg), + payload => emqx_message:payload(NMsg), + timestamp => emqx_message:timestamp(NMsg) + } + end, Delivers), + Req = #{messages => Msgs}, + {ok, try_dispatch(on_received_messages, wrap(Req), Channel)}. -spec(handle_timeout(reference(), Msg :: term(), channel()) -> {ok, channel()} | {shutdown, Reason :: term(), channel()}). +handle_timeout(_TRef, {keepalive, _StatVal}, + Channel = #channel{keepalive = undefined}) -> + {ok, Channel}; +handle_timeout(_TRef, {keepalive, StatVal}, + Channel = #channel{keepalive = Keepalive}) -> + case emqx_keepalive:check(StatVal, Keepalive) of + {ok, NKeepalive} -> + NChannel = Channel#channel{keepalive = NKeepalive}, + {ok, reset_timer(alive_timer, NChannel)}; + {error, timeout} -> + Req = #{type => 'KEEPALIVE'}, + {ok, try_dispatch(on_timer_timeout, wrap(Req), Channel)} + end; + +handle_timeout(_TRef, force_close, Channel = #channel{closed_reason = Reason}) -> + {shutdown, {error, {force_close, Reason}}, Channel}; + handle_timeout(_TRef, Msg, Channel) -> ?WARN("Unexpected timeout: ~p", [Msg]), {ok, Channel}. -spec(handle_call(any(), channel()) -> {reply, Reply :: term(), channel()} + | {reply, Reply :: term(), replies(), channel()} | {shutdown, Reason :: term(), Reply :: term(), channel()}). + +handle_call({send, Data}, Channel) -> + {reply, ok, [{outgoing, Data}], Channel}; + +handle_call(close, Channel = #channel{conn_state = connected}) -> + {reply, ok, [{event, disconnected}, {close, normal}], Channel}; +handle_call(close, Channel) -> + {reply, ok, [{close, normal}], Channel}; + +handle_call({auth, ClientInfo, _Password}, Channel = #channel{conn_state = connected}) -> + ?LOG(warning, "Duplicated authorized command, dropped ~p", [ClientInfo]), + {ok, {error, ?RESP_PERMISSION_DENY, <<"Duplicated authenticate command">>}, Channel}; +handle_call({auth, ClientInfo0, Password}, + Channel = #channel{conninfo = ConnInfo, + clientinfo = ClientInfo}) -> + ClientInfo1 = enrich_clientinfo(ClientInfo0, ClientInfo), + NConnInfo = enrich_conninfo(ClientInfo1, ConnInfo), + + Channel1 = Channel#channel{conninfo = NConnInfo, + clientinfo = ClientInfo1}, + + #{clientid := ClientId, username := Username} = ClientInfo1, + + case emqx_access_control:authenticate(ClientInfo1#{password => Password}) of + {ok, AuthResult} -> + emqx_logger:set_metadata_clientid(ClientId), + is_anonymous(AuthResult) andalso + emqx_metrics:inc('client.auth.anonymous'), + NClientInfo = maps:merge(ClientInfo1, AuthResult), + NChannel = Channel1#channel{clientinfo = NClientInfo}, + case emqx_cm:open_session(true, NClientInfo, NConnInfo) of + {ok, _Session} -> + ?LOG(debug, "Client ~s (Username: '~s') authorized successfully!", + [ClientId, Username]), + {reply, ok, [{event, connected}], ensure_connected(NChannel)}; + {error, Reason} -> + ?LOG(warning, "Client ~s (Username: '~s') open session failed for ~0p", + [ClientId, Username, Reason]), + {reply, {error, ?RESP_PERMISSION_DENY, Reason}, Channel} + end; + {error, Reason} -> + ?LOG(warning, "Client ~s (Username: '~s') login failed for ~0p", + [ClientId, Username, Reason]), + {reply, {error, ?RESP_PERMISSION_DENY, Reason}, Channel} + end; + +handle_call({start_timer, keepalive, Interval}, + Channel = #channel{ + conninfo = ConnInfo, + clientinfo = ClientInfo + }) -> + NConnInfo = ConnInfo#{keepalive => Interval}, + NClientInfo = ClientInfo#{keepalive => Interval}, + NChannel = Channel#channel{conninfo = NConnInfo, clientinfo = NClientInfo}, + {reply, ok, ensure_keepalive(NChannel)}; + +handle_call({subscribe, TopicFilter, Qos}, + Channel = #channel{ + conn_state = connected, + clientinfo = ClientInfo}) -> + case is_acl_enabled(ClientInfo) andalso + emqx_access_control:check_acl(ClientInfo, subscribe, TopicFilter) of + deny -> + {reply, {error, ?RESP_PERMISSION_DENY, <<"ACL deny">>}, Channel}; + _ -> + {ok, NChannel} = do_subscribe([{TopicFilter, #{qos => Qos}}], Channel), + {reply, ok, NChannel} + end; + +handle_call({unsubscribe, TopicFilter}, + Channel = #channel{conn_state = connected}) -> + {ok, NChannel} = do_unsubscribe([{TopicFilter, #{}}], Channel), + {reply, ok, NChannel}; + +handle_call({publish, Topic, Qos, Payload}, + Channel = #channel{ + conn_state = connected, + clientinfo = ClientInfo + = #{clientid := From, + mountpoint := Mountpoint}}) -> + case is_acl_enabled(ClientInfo) andalso + emqx_access_control:check_acl(ClientInfo, publish, Topic) of + deny -> + {reply, {error, ?RESP_PERMISSION_DENY, <<"ACL deny">>}, Channel}; + _ -> + Msg = emqx_message:make(From, Qos, Topic, Payload), + NMsg = emqx_mountpoint:mount(Mountpoint, Msg), + emqx:publish(NMsg), + {reply, ok, Channel} + end; + handle_call(kick, Channel) -> {shutdown, kicked, ok, Channel}; handle_call(Req, Channel) -> - ?WARN("Unexpected call: ~p", [Req]), - {reply, ok, Channel}. + ?LOG(warning, "Unexpected call: ~p", [Req]), + {reply, {error, unexpected_call}, Channel}. -spec(handle_cast(any(), channel()) -> {ok, channel()} | {ok, replies(), channel()} | {shutdown, Reason :: term(), channel()}). -handle_cast({send, Data}, Channel) -> - {ok, [{outgoing, Data}], Channel}; - -handle_cast(close, Channel) -> - {ok, [{close, normal}], Channel}; - -handle_cast({register, ClientInfo}, Channel = #channel{registered = true}) -> - ?WARN("Duplicated register command, dropped ~p", [ClientInfo]), - {ok, Channel}; -handle_cast({register, ClientInfo0}, Channel = #channel{conninfo = ConnInfo, - clientinfo = ClientInfo}) -> - ClientInfo1 = maybe_assign_clientid(ClientInfo0), - NConnInfo = enrich_conninfo(ClientInfo1, ConnInfo), - NClientInfo = enrich_clientinfo(ClientInfo1, ClientInfo), - case emqx_cm:open_session(true, NClientInfo, NConnInfo) of - {ok, _Session} -> - NChannel = Channel#channel{registered = true, - conninfo = NConnInfo, - clientinfo = NClientInfo}, - {ok, [{event, registered}], NChannel}; - {error, Reason} -> - ?ERROR("Register failed, reason: ~p", [Reason]), - {shutdown, Reason, {error, Reason}, Channel} - end; - -handle_cast({subscribe, TopicFilter, Qos}, Channel) -> - do_subscribe([{TopicFilter, #{qos => Qos}}], Channel); - -handle_cast({unsubscribe, TopicFilter}, Channel) -> - do_unsubscribe([{TopicFilter, #{}}], Channel); - -handle_cast({publish, Msg}, Channel) -> - emqx:publish(enrich_msg(Msg, Channel)), - {ok, Channel}; - handle_cast(Req, Channel) -> ?WARN("Unexpected call: ~p", [Req]), {ok, Channel}. @@ -241,15 +359,41 @@ handle_info({subscribe, TopicFilters}, Channel) -> handle_info({unsubscribe, TopicFilters}, Channel) -> do_unsubscribe(TopicFilters, Channel); -handle_info({sock_closed, Reason}, Channel) -> - {shutdown, {sock_closed, Reason}, Channel}; +handle_info({sock_closed, Reason}, + Channel = #channel{rqueue = Queue, inflight = Inflight}) -> + case queue:len(Queue) =:= 0 + andalso Inflight =:= undefined of + true -> + {shutdown, {sock_closed, Reason}, Channel}; + _ -> + %% delayed close process for flushing all callback funcs to gRPC server + Channel1 = Channel#channel{closed_reason = {sock_closed, Reason}}, + Channel2 = ensure_timer(force_timer, Channel1), + {ok, ensure_disconnected({sock_closed, Reason}, Channel2)} + end; + +handle_info({hreply, on_socket_created, {ok, _}}, Channel) -> + dispatch_or_close_process(Channel#channel{inflight = undefined}); +handle_info({hreply, FunName, {ok, _}}, Channel) + when FunName == on_socket_closed; + FunName == on_received_bytes; + FunName == on_received_messages; + FunName == on_timer_timeout -> + dispatch_or_close_process(Channel#channel{inflight = undefined}); +handle_info({hreply, FunName, {error, Reason}}, Channel) -> + {shutdown, {error, {FunName, Reason}}, Channel}; + handle_info(Info, Channel) -> - ?WARN("Unexpected info: ~p", [Info]), + ?LOG(warning, "Unexpected info: ~p", [Info]), {ok, Channel}. --spec(terminate(any(), channel()) -> ok). +-spec(terminate(any(), channel()) -> channel()). terminate(Reason, Channel) -> - cb_terminated(Reason, Channel), ok. + Req = #{reason => stringfy(Reason)}, + try_dispatch(on_socket_closed, wrap(Req), Channel). + +is_anonymous(#{anonymous := true}) -> true; +is_anonymous(_AuthResult) -> false. %%-------------------------------------------------------------------- %% Sub/UnSub @@ -266,11 +410,22 @@ do_subscribe(TopicFilters, Channel) -> do_subscribe(TopicFilter, SubOpts, Channel = #channel{clientinfo = ClientInfo = #{mountpoint := Mountpoint}, subscriptions = Subs}) -> + %% Mountpoint first NTopicFilter = emqx_mountpoint:mount(Mountpoint, TopicFilter), NSubOpts = maps:merge(?DEFAULT_SUBOPTS, SubOpts), SubId = maps:get(clientid, ClientInfo, undefined), - _ = emqx:subscribe(NTopicFilter, SubId, NSubOpts), - Channel#channel{subscriptions = Subs#{NTopicFilter => SubOpts}}. + IsNew = not maps:is_key(NTopicFilter, Subs), + case IsNew of + true -> + ok = emqx:subscribe(NTopicFilter, SubId, NSubOpts), + ok = emqx_hooks:run('session.subscribed', + [ClientInfo, NTopicFilter, NSubOpts#{is_new => IsNew}]), + Channel#channel{subscriptions = Subs#{NTopicFilter => NSubOpts}}; + _ -> + %% Update subopts + ok = emqx:subscribe(NTopicFilter, SubId, NSubOpts), + Channel#channel{subscriptions = Subs#{NTopicFilter => NSubOpts}} + end. do_unsubscribe(TopicFilters, Channel) -> NChannel = lists:foldl( @@ -280,74 +435,133 @@ do_unsubscribe(TopicFilters, Channel) -> {ok, NChannel}. %% @private -do_unsubscribe(TopicFilter, _SubOpts, Channel = - #channel{clientinfo = #{mountpoint := Mountpoint}, +do_unsubscribe(TopicFilter, UnSubOpts, Channel = + #channel{clientinfo = ClientInfo = #{mountpoint := Mountpoint}, subscriptions = Subs}) -> - TopicFilter1 = emqx_mountpoint:mount(Mountpoint, TopicFilter), - _ = emqx:unsubscribe(TopicFilter1), - Channel#channel{subscriptions = maps:remove(TopicFilter1, Subs)}. + NTopicFilter = emqx_mountpoint:mount(Mountpoint, TopicFilter), + case maps:find(NTopicFilter, Subs) of + {ok, SubOpts} -> + ok = emqx:unsubscribe(NTopicFilter), + ok = emqx_hooks:run('session.unsubscribed', + [ClientInfo, TopicFilter, maps:merge(SubOpts, UnSubOpts)]), + Channel#channel{subscriptions = maps:remove(NTopicFilter, Subs)}; + _ -> + Channel + end. %% @private parse_topic_filters(TopicFilters) -> lists:map(fun emqx_topic:parse/1, TopicFilters). +-compile({inline, [is_acl_enabled/1]}). +is_acl_enabled(#{zone := Zone, is_superuser := IsSuperuser}) -> + (not IsSuperuser) andalso emqx_zone:enable_acl(Zone). + %%-------------------------------------------------------------------- -%% Cbs for driver +%% Ensure & Hooks %%-------------------------------------------------------------------- -cb_init(ConnInfo, Driver) -> - Args = [self(), emqx_exproto_types:serialize(conninfo, ConnInfo)], - emqx_exproto_driver_mngr:call(Driver, {'init', Args}). +ensure_connected(Channel = #channel{conninfo = ConnInfo, + clientinfo = ClientInfo}) -> + NConnInfo = ConnInfo#{connected_at => erlang:system_time(millisecond)}, + ok = run_hooks('client.connected', [ClientInfo, NConnInfo]), + Channel#channel{conninfo = NConnInfo, + conn_state = connected + }. -cb_received(Data, Channel = #channel{state = DState}) -> - Args = [self(), Data, DState], - do_call_cb('received', Args, Channel). +ensure_disconnected(Reason, Channel = #channel{ + conn_state = connected, + conninfo = ConnInfo, + clientinfo = ClientInfo}) -> + NConnInfo = ConnInfo#{disconnected_at => erlang:system_time(millisecond)}, + ok = run_hooks('client.disconnected', [ClientInfo, Reason, NConnInfo]), + Channel#channel{conninfo = NConnInfo, conn_state = disconnected}; -cb_terminated(Reason, Channel = #channel{state = DState}) -> - Args = [self(), stringfy(Reason), DState], - do_call_cb('terminated', Args, Channel). +ensure_disconnected(_Reason, Channel = #channel{conninfo = ConnInfo}) -> + NConnInfo = ConnInfo#{disconnected_at => erlang:system_time(millisecond)}, + Channel#channel{conninfo = NConnInfo, conn_state = disconnected}. -cb_deliver(Delivers, Channel = #channel{state = DState}) -> - Msgs = [emqx_exproto_types:serialize(message, Msg) || {_, _, Msg} <- Delivers], - Args = [self(), Msgs, DState], - do_call_cb('deliver', Args, Channel). +run_hooks(Name, Args) -> + ok = emqx_metrics:inc(Name), emqx_hooks:run(Name, Args). -%% @private -do_call_cb(Fun, Args, Channel = #channel{driver = D}) -> - case emqx_exproto_driver_mngr:call(D, {Fun, Args}) of - ok -> - {ok, Channel}; - {ok, NDState} -> - {ok, Channel#channel{state = NDState}}; - {error, Reason} -> - {error, Reason} +%%-------------------------------------------------------------------- +%% Enrich Keepalive + +ensure_keepalive(Channel = #channel{clientinfo = ClientInfo}) -> + ensure_keepalive_timer(maps:get(keepalive, ClientInfo, 0), Channel). + +ensure_keepalive_timer(Interval, Channel) when Interval =< 0 -> + Channel; +ensure_keepalive_timer(Interval, Channel) -> + Keepalive = emqx_keepalive:init(timer:seconds(Interval)), + ensure_timer(alive_timer, Channel#channel{keepalive = Keepalive}). + +ensure_timer(Name, Channel = #channel{timers = Timers}) -> + TRef = maps:get(Name, Timers, undefined), + Time = interval(Name, Channel), + case TRef == undefined andalso Time > 0 of + true -> ensure_timer(Name, Time, Channel); + false -> Channel %% Timer disabled or exists end. +ensure_timer(Name, Time, Channel = #channel{timers = Timers}) -> + Msg = maps:get(Name, ?TIMER_TABLE), + TRef = emqx_misc:start_timer(Time, Msg), + Channel#channel{timers = Timers#{Name => TRef}}. + +reset_timer(Name, Channel) -> + ensure_timer(Name, clean_timer(Name, Channel)). + +clean_timer(Name, Channel = #channel{timers = Timers}) -> + Channel#channel{timers = maps:remove(Name, Timers)}. + +interval(force_timer, _) -> + 15000; +interval(alive_timer, #channel{keepalive = Keepalive}) -> + emqx_keepalive:info(interval, Keepalive). + +%%-------------------------------------------------------------------- +%% Dispatch +%%-------------------------------------------------------------------- + +wrap(Req) -> + Req#{conn => pid_to_list(self())}. + +dispatch_or_close_process(Channel = #channel{ + rqueue = Queue, + inflight = undefined, + gcli = GClient}) -> + case queue:out(Queue) of + {empty, _} -> + case Channel#channel.conn_state of + disconnected -> + {shutdown, Channel#channel.closed_reason, Channel}; + _ -> + {ok, Channel} + end; + {{value, {FunName, Req}}, NQueue} -> + emqx_exproto_gcli:async_call(FunName, Req, GClient), + {ok, Channel#channel{inflight = FunName, rqueue = NQueue}} + end. + +try_dispatch(FunName, Req, Channel = #channel{inflight = undefined, gcli = GClient}) -> + emqx_exproto_gcli:async_call(FunName, Req, GClient), + Channel#channel{inflight = FunName}; +try_dispatch(FunName, Req, Channel = #channel{rqueue = Queue}) -> + Channel#channel{rqueue = queue:in({FunName, Req}, Queue)}. + %%-------------------------------------------------------------------- %% Format %%-------------------------------------------------------------------- -maybe_assign_clientid(ClientInfo) -> - case maps:get(clientid, ClientInfo, undefined) of - undefined -> - ClientInfo#{clientid => emqx_guid:to_base62(emqx_guid:gen())}; - _ -> - ClientInfo - end. - -enrich_msg(Msg, #channel{clientinfo = ClientInfo = #{mountpoint := Mountpoint}}) -> - NMsg = emqx_mountpoint:mount(Mountpoint, Msg), - case maps:get(clientid, ClientInfo, undefined) of - undefined -> NMsg; - ClientId -> NMsg#message{from = ClientId} - end. - enrich_conninfo(InClientInfo, ConnInfo) -> - maps:merge(ConnInfo, maps:with([proto_name, proto_ver, clientid, username, keepalive], InClientInfo)). + Ks = [proto_name, proto_ver, clientid, username], + maps:merge(ConnInfo, maps:with(Ks, InClientInfo)). enrich_clientinfo(InClientInfo = #{proto_name := ProtoName}, ClientInfo) -> - NClientInfo = maps:merge(ClientInfo, maps:with([clientid, username, mountpoint], InClientInfo)), - NClientInfo#{protocol => lowcase_atom(ProtoName)}. + Ks = [clientid, username, mountpoint], + NClientInfo = maps:merge(ClientInfo, maps:with(Ks, InClientInfo)), + NClientInfo#{protocol => ProtoName}. default_conninfo(ConnInfo) -> ConnInfo#{proto_name => undefined, @@ -363,12 +577,12 @@ default_conninfo(ConnInfo) -> expiry_interval => 0}. default_clientinfo(#{peername := {PeerHost, _}, - sockname := {_, SockPort}}) -> - #{zone => undefined, + sockname := {_, SockPort}}) -> + #{zone => external, protocol => undefined, peerhost => PeerHost, sockport => SockPort, - clientid => default_clientid(), + clientid => undefined, username => undefined, is_bridge => false, is_superuser => false, @@ -377,10 +591,9 @@ default_clientinfo(#{peername := {PeerHost, _}, stringfy(Reason) -> unicode:characters_to_binary((io_lib:format("~0p", [Reason]))). -lowcase_atom(undefined) -> - undefined; -lowcase_atom(S) -> - binary_to_atom(string:lowercase(S), utf8). +hexstr(Bin) -> + [io_lib:format("~2.16.0B",[X]) || <> <= Bin]. -default_clientid() -> - <<"exproto_client_", (list_to_binary(pid_to_list(self())))/binary>>. +fmt_from(undefined) -> <<>>; +fmt_from(Bin) when is_binary(Bin) -> Bin; +fmt_from(T) -> stringfy(T). diff --git a/apps/emqx_exproto/src/emqx_exproto_conn.erl b/apps/emqx_exproto/src/emqx_exproto_conn.erl index 10a40c987..e6f7676a1 100644 --- a/apps/emqx_exproto/src/emqx_exproto_conn.erl +++ b/apps/emqx_exproto/src/emqx_exproto_conn.erl @@ -61,8 +61,9 @@ sockstate :: emqx_types:sockstate(), %% The {active, N} option active_n :: pos_integer(), - %% Send function - sendfun :: function(), + %% BACKW: e4.2.0-e4.2.1 + %% We should remove it + sendfun :: function() | undefined, %% Limiter limiter :: maybe(emqx_limiter:limiter()), %% Limit Timer @@ -173,8 +174,10 @@ esockd_wait({esockd_transport, Sock}) -> R = {error, _} -> R end. -esockd_close({udp, _SockPid, Sock}) -> - gen_udp:close(Sock); +esockd_close({udp, _SockPid, _Sock}) -> + %% nothing to do for udp socket + %%gen_udp:close(Sock); + ok; esockd_close({esockd_transport, Sock}) -> esockd_transport:fast_close(Sock). @@ -201,14 +204,10 @@ esockd_getstat({udp, _SockPid, Sock}, Stats) -> esockd_getstat({esockd_transport, Sock}, Stats) -> esockd_transport:getstat(Sock, Stats). -sendfun({udp, _SockPid, Sock}, {Ip, Port}) -> - fun(Data) -> - gen_udp:send(Sock, Ip, Port, Data) - end; -sendfun({esockd_transport, Sock}, _) -> - fun(Data) -> - esockd_transport:async_send(Sock, Data) - end. +send(Data, #state{socket = {udp, _SockPid, Sock}, peername = {Ip, Port}}) -> + gen_udp:send(Sock, Ip, Port, Data); +send(Data, #state{socket = {esockd_transport, Sock}}) -> + esockd_transport:async_send(Sock, Data). %%-------------------------------------------------------------------- %% callbacks @@ -253,7 +252,7 @@ init_state(WrappedSock, Peername, Options) -> sockname = Sockname, sockstate = idle, active_n = ActiveN, - sendfun = sendfun(WrappedSock, Peername), + sendfun = undefined, limiter = undefined, channel = Channel, gc_state = GcState, @@ -357,6 +356,9 @@ handle_msg({'$gen_call', From, Req}, State) -> {reply, Reply, NState} -> gen_server:reply(From, Reply), {ok, NState}; + {reply, Reply, Msgs, NState} -> + gen_server:reply(From, Reply), + {ok, next_msgs(Msgs), NState}; {stop, Reason, Reply, NState} -> gen_server:reply(From, Reply), stop(Reason, NState) @@ -419,16 +421,16 @@ handle_msg({close, Reason}, State) -> ?LOG(debug, "Force to close the socket due to ~p", [Reason]), handle_info({sock_closed, Reason}, close_socket(State)); -handle_msg({event, registered}, State = #state{channel = Channel}) -> +handle_msg({event, connected}, State = #state{channel = Channel}) -> ClientId = emqx_exproto_channel:info(clientid, Channel), emqx_cm:register_channel(ClientId, info(State), stats(State)); -%handle_msg({event, disconnected}, State = #state{channel = Channel}) -> -% ClientId = emqx_exproto_channel:info(clientid, Channel), -% emqx_cm:set_chan_info(ClientId, info(State)), -% emqx_cm:connection_closed(ClientId), -% {ok, State}; -% +handle_msg({event, disconnected}, State = #state{channel = Channel}) -> + ClientId = emqx_exproto_channel:info(clientid, Channel), + emqx_cm:set_chan_info(ClientId, info(State)), + emqx_cm:connection_closed(ClientId), + {ok, State}; + %handle_msg({event, _Other}, State = #state{channel = Channel}) -> % ClientId = emqx_exproto_channel:info(clientid, Channel), % emqx_cm:set_chan_info(ClientId, info(State)), @@ -480,6 +482,8 @@ handle_call(_From, Req, State = #state{channel = Channel}) -> case emqx_exproto_channel:handle_call(Req, Channel) of {reply, Reply, NChannel} -> {reply, Reply, State#state{channel = NChannel}}; + {reply, Reply, Replies, NChannel} -> + {reply, Reply, Replies, State#state{channel = NChannel}}; {shutdown, Reason, Reply, NChannel} -> shutdown(Reason, Reply, State#state{channel = NChannel}) end. @@ -495,7 +499,18 @@ handle_timeout(_TRef, limit_timeout, State) -> limit_timer = undefined }, handle_info(activate_socket, NState); - +handle_timeout(TRef, keepalive, State = #state{socket = Socket, + channel = Channel})-> + case emqx_exproto_channel:info(conn_state, Channel) of + disconnected -> {ok, State}; + _ -> + case esockd_getstat(Socket, [recv_oct]) of + {ok, [{recv_oct, RecvOct}]} -> + handle_timeout(TRef, {keepalive, RecvOct}, State); + {error, Reason} -> + handle_info({sock_error, Reason}, State) + end + end; handle_timeout(_TRef, emit_stats, State = #state{channel = Channel}) -> ClientId = emqx_exproto_channel:info(clientid, Channel), @@ -541,7 +556,7 @@ with_channel(Fun, Args, State = #state{channel = Channel}) -> %%-------------------------------------------------------------------- %% Handle outgoing packets -handle_outgoing(IoData, #state{socket = Socket, sendfun = SendFun}) -> +handle_outgoing(IoData, State = #state{socket = Socket}) -> ?LOG(debug, "SEND ~0p", [IoData]), Oct = iolist_size(IoData), @@ -553,7 +568,7 @@ handle_outgoing(IoData, #state{socket = Socket, sendfun = SendFun}) -> %% FIXME: %%ok = emqx_metrics:inc('bytes.sent', Oct), - case SendFun(IoData) of + case send(IoData, State) of ok -> ok; Error = {error, _Reason} -> %% Send an inet_reply to postpone handling the error @@ -665,4 +680,3 @@ stop(Reason, State) -> stop(Reason, Reply, State) -> {stop, Reason, Reply, State}. - diff --git a/apps/emqx_exproto/src/emqx_exproto_driver_mngr.erl b/apps/emqx_exproto/src/emqx_exproto_driver_mngr.erl deleted file mode 100644 index 4a088bb99..000000000 --- a/apps/emqx_exproto/src/emqx_exproto_driver_mngr.erl +++ /dev/null @@ -1,302 +0,0 @@ -%%-------------------------------------------------------------------- -%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. -%% -%% Licensed under the Apache License, Version 2.0 (the "License"); -%% you may not use this file except in compliance with the License. -%% You may obtain a copy of the License at -%% -%% http://www.apache.org/licenses/LICENSE-2.0 -%% -%% Unless required by applicable law or agreed to in writing, software -%% distributed under the License is distributed on an "AS IS" BASIS, -%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -%% See the License for the specific language governing permissions and -%% limitations under the License. -%%-------------------------------------------------------------------- - --module(emqx_exproto_driver_mngr). - --behaviour(gen_server). - --include_lib("emqx/include/logger.hrl"). - --log_header("[ExProto DMngr]"). - --compile({no_auto_import, [erase/1, get/1]}). - -%% API --export([start_link/0]). - -%% Manager APIs --export([ ensure_driver/2 - , stop_drivers/0 - , stop_driver/1 - ]). - -%% Driver APIs --export([ lookup/1 - , call/2 - ]). - -%% gen_server callbacks --export([ init/1 - , handle_call/3 - , handle_cast/2 - , handle_info/2 - , terminate/2 - , code_change/3 - ]). - --define(SERVER, ?MODULE). --define(DEFAULT_CBM, main). - --type driver() :: #{name := driver_name(), - type := atom(), - cbm := atom(), - pid := pid(), - opts := list() - }. - --type driver_name() :: atom(). - --type fargs() :: {atom(), list()}. - -%%-------------------------------------------------------------------- -%% APIs -%%-------------------------------------------------------------------- - -start_link() -> - gen_server:start_link({local, ?SERVER}, ?MODULE, [], []). - -%%-------------------------------------------------------------------- -%% APIs - Managers -%%-------------------------------------------------------------------- - --spec(ensure_driver(driver_name(), list()) -> {ok, pid()} | {error, any()}). -ensure_driver(Name, Opts) -> - {value, {_, Type}, Opts1} = lists:keytake(type, 1, Opts), - {value, {_, Cbm}, Opts2} = lists:keytake(cbm, 1, Opts1), - gen_server:call(?SERVER, {ensure, {Type, Name, Cbm, Opts2}}). - --spec(stop_drivers() -> ok). -stop_drivers() -> - gen_server:call(?SERVER, stop_all). - --spec(stop_driver(driver_name()) -> ok). -stop_driver(Name) -> - gen_server:call(?SERVER, {stop, Name}). - -%%-------------------------------------------------------------------- -%% APIs - Drivers -%%-------------------------------------------------------------------- - --spec(lookup(driver_name()) -> {ok, driver()} | {error, any()}). -lookup(Name) -> - case catch persistent_term:get({?MODULE, Name}) of - {'EXIT', {badarg, _}} -> {error, not_found}; - Driver when is_map(Driver) -> {ok, Driver} - end. - --spec(call(driver_name(), fargs()) -> ok | {ok, any()} | {error, any()}). -call(Name, FArgs) -> - ensure_alived(Name, fun(Driver) -> do_call(Driver, FArgs) end). - -%% @private -ensure_alived(Name, Fun) -> - case catch get(Name) of - {'EXIT', _} -> - {error, not_found}; - Driver -> - ensure_alived(10, Driver, Fun) - end. - -%% @private -ensure_alived(0, _, _) -> - {error, driver_process_exited}; -ensure_alived(N, Driver = #{name := Name, pid := Pid}, Fun) -> - case is_process_alive(Pid) of - true -> Fun(Driver); - _ -> - timer:sleep(100), - #{pid := NPid} = get(Name), - case is_process_alive(NPid) of - true -> Fun(Driver); - _ -> ensure_alived(N-1, Driver#{pid => NPid}, Fun) - end - end. - -%% @private -do_call(#{type := Type, pid := Pid, cbm := Cbm}, {F, Args}) -> - case catch apply(erlport, call, [Pid, Cbm, F, Args, []]) of - ok -> ok; - undefined -> ok; - {_Ok = 0, Return} -> {ok, Return}; - {_Err = 1, Reason} -> {error, Reason}; - {'EXIT', Reason, Stk} -> - ?LOG(error, "CALL ~p ~p:~p(~p), exception: ~p, stacktrace ~0p", - [Type, Cbm, F, Args, Reason, Stk]), - {error, Reason}; - _X -> - ?LOG(error, "CALL ~p ~p:~p(~p), unknown return: ~0p", - [Type, Cbm, F, Args, _X]), - {error, unknown_return_format} - end. - -%%-------------------------------------------------------------------- -%% gen_server callbacks -%%-------------------------------------------------------------------- - -init([]) -> - process_flag(trap_exit, true), - {ok, #{drivers => []}}. - -handle_call({ensure, {Type, Name, Cbm, Opts}}, _From, State = #{drivers := Drivers}) -> - case lists:keyfind(Name, 1, Drivers) of - false -> - case do_start_driver(Type, Opts) of - {ok, Pid} -> - Driver = #{name => Name, - type => Type, - cbm => Cbm, - pid => Pid, - opts => Opts}, - ok = save(Name, Driver), - reply({ok, Driver}, State#{drivers => [{Name, Driver} | Drivers]}); - {error, Reason} -> - reply({error, Reason}, State) - end; - {_, Driver} -> - reply({ok, Driver}, State) - end; - -handle_call(stop_all, _From, State = #{drivers := Drivers}) -> - lists:foreach( - fun({Name, #{pid := Pid}}) -> - _ = do_stop_drviver(Pid), - _ = erase(Name) - end, Drivers), - reply(ok, State#{drivers => []}); - -handle_call({stop, Name}, _From, State = #{drivers := Drivers}) -> - case lists:keyfind(Name, 1, Drivers) of - false -> - reply({error, not_found}, State); - {_, #{pid := Pid}} -> - _ = do_stop_drviver(Pid), - _ = erase(Name), - reply(ok, State#{drivers => Drivers -- [{Name, Pid}]}) - end; - -handle_call(Req, _From, State) -> - ?WARN("Unexpected request: ~p", [Req]), - {reply, ok, State}. - -handle_cast(Msg, State) -> - ?WARN("Unexpected cast: ~p", [Msg]), - {noreply, State}. - -handle_info({'EXIT', _From, normal}, State) -> - {noreply, State}; -handle_info({'EXIT', From, Reason}, State = #{drivers := Drivers}) -> - case [Drv || {_, Drv = #{pid := P}} <- Drivers, P =:= From] of - [] -> {noreply, State}; - [Driver = #{name := Name, type := Type, opts := Opts}] -> - ?WARN("Driver ~p crashed: ~p", [Name, Reason]), - case do_start_driver(Type, Opts) of - {ok, Pid} -> - NDriver = Driver#{pid => Pid}, - ok = save(Name, NDriver), - NDrivers = lists:keyreplace(Name, 1, Drivers, {Name, NDriver}), - ?WARN("Restarted driver ~p, pid: ~p", [Name, Pid]), - {noreply, State#{drivers => NDrivers}}; - {error, Reason} -> - ?WARN("Restart driver ~p failed: ~p", [Name, Reason]), - {noreply, State} - end - end; - -handle_info(Info, State) -> - ?WARN("Unexpected info: ~p", [Info]), - {noreply, State}. - -terminate(_Reason, _State) -> - ok. - -code_change(_OldVsn, State, _Extra) -> - {ok, State}. - -%%-------------------------------------------------------------------- -%% Internal funcs -%%-------------------------------------------------------------------- - -do_start_driver(Type, Opts) - when Type =:= python2; - Type =:= python3 -> - NOpts = resovle_search_path(python, Opts), - python:start_link([{python, atom_to_list(Type)} | NOpts]); - -do_start_driver(Type, Opts) - when Type =:= java -> - NOpts = resovle_search_path(java, Opts), - java:start_link([{java, atom_to_list(Type)} | NOpts]); - -do_start_driver(Type, _) -> - {error, {invalid_driver_type, Type}}. - -do_stop_drviver(DriverPid) -> - erlport:stop(DriverPid). -%% @private -resovle_search_path(java, Opts) -> - case lists:keytake(path, 1, Opts) of - false -> Opts; - {value, {_, Path}, NOpts} -> - Solved = lists:flatten( - lists:join(pathsep(), - [expand_jar_packages(filename:absname(P)) - || P <- re:split(Path, pathsep(), [{return, list}]), P /= ""])), - [{java_path, Solved} | NOpts] - end; -resovle_search_path(python, Opts) -> - case lists:keytake(path, 1, Opts) of - false -> Opts; - {value, {_, Path}, NOpts} -> - [{python_path, Path} | NOpts] - end. - -%% @private -expand_jar_packages(Path) -> - IsJarPkgs = fun(Name) -> - Ext = filename:extension(Name), - Ext == ".jar" orelse Ext == ".zip" - end, - case file:list_dir(Path) of - {ok, []} -> [Path]; - {error, _} -> [Path]; - {ok, Names} -> - lists:join(pathsep(), - [Path] ++ [filename:join([Path, Name]) || Name <- Names, IsJarPkgs(Name)]) - end. - -%% @private -pathsep() -> - case os:type() of - {win32, _} -> - ";"; - _ -> - ":" - end. - -%%-------------------------------------------------------------------- -%% Utils - -reply(Term, State) -> - {reply, Term, State}. - -save(Name, Driver) -> - persistent_term:put({?MODULE, Name}, Driver). - -erase(Name) -> - persistent_term:erase({?MODULE, Name}). - -get(Name) -> - persistent_term:get({?MODULE, Name}). diff --git a/apps/emqx_exproto/src/emqx_exproto_gcli.erl b/apps/emqx_exproto/src/emqx_exproto_gcli.erl new file mode 100644 index 000000000..5ae9b0209 --- /dev/null +++ b/apps/emqx_exproto/src/emqx_exproto_gcli.erl @@ -0,0 +1,110 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +%% the gRPC client worker for ConnectionHandler service +-module(emqx_exproto_gcli). + +-behaviour(gen_server). + +-include_lib("emqx/include/logger.hrl"). + +-logger_header("[ExProto gClient]"). + +%% APIs +-export([async_call/3]). + +-export([start_link/2]). + +%% gen_server callbacks +-export([ init/1 + , handle_call/3 + , handle_cast/2 + , handle_info/2 + , terminate/2 + , code_change/3 + ]). + +-define(CONN_ADAPTER_MOD, emqx_exproto_v_1_connection_handler_client). + +%%-------------------------------------------------------------------- +%% APIs +%%-------------------------------------------------------------------- + +start_link(Pool, Id) -> + gen_server:start_link({local, emqx_misc:proc_name(?MODULE, Id)}, + ?MODULE, [Pool, Id], []). + +async_call(FunName, Req = #{conn := Conn}, Options) -> + cast(pick(Conn), {rpc, FunName, Req, Options, self()}). + +%%-------------------------------------------------------------------- +%% cast, pick +%%-------------------------------------------------------------------- + +-compile({inline, [cast/2, pick/1]}). + +cast(Deliver, Msg) -> + gen_server:cast(Deliver, Msg). + +pick(Conn) -> + gproc_pool:pick_worker(exproto_gcli_pool, Conn). + +%%-------------------------------------------------------------------- +%% gen_server callbacks +%%-------------------------------------------------------------------- + +init([Pool, Id]) -> + true = gproc_pool:connect_worker(Pool, {Pool, Id}), + {ok, #{pool => Pool, id => Id}}. + +handle_call(_Request, _From, State) -> + {reply, ok, State}. + +handle_cast({rpc, Fun, Req, Options, From}, State) -> + case catch apply(?CONN_ADAPTER_MOD, Fun, [Req, Options]) of + {ok, Resp, _Metadata} -> + ?LOG(debug, "~p got {ok, ~0p, ~0p}", [Fun, Resp, _Metadata]), + reply(From, Fun, {ok, Resp}); + {error, {Code, Msg}, _Metadata} -> + ?LOG(error, "CALL ~0p:~0p(~0p, ~0p) response errcode: ~0p, errmsg: ~0p", + [?CONN_ADAPTER_MOD, Fun, Req, Options, Code, Msg]), + reply(From, Fun, {error, {Code, Msg}}); + {error, Reason} -> + ?LOG(error, "CALL ~0p:~0p(~0p, ~0p) error: ~0p", + [?CONN_ADAPTER_MOD, Fun, Req, Options, Reason]), + reply(From, Fun, {error, Reason}); + {'EXIT', {Reason, Stk}} -> + ?LOG(error, "CALL ~0p:~0p(~0p, ~0p) throw an exception: ~0p, stacktrace: ~0p", + [?CONN_ADAPTER_MOD, Fun, Req, Options, Reason, Stk]), + reply(From, Fun, {error, Reason}) + end, + {noreply, State}. + +handle_info(_Info, State) -> + {noreply, State}. + +terminate(_Reason, _State) -> + ok. + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + +%%-------------------------------------------------------------------- +%% Internal funcs +%%-------------------------------------------------------------------- + +reply(Pid, Fun, Result) -> + Pid ! {hreply, Fun, Result}. diff --git a/apps/emqx_exproto/src/emqx_exproto_gsvr.erl b/apps/emqx_exproto/src/emqx_exproto_gsvr.erl new file mode 100644 index 000000000..286784009 --- /dev/null +++ b/apps/emqx_exproto/src/emqx_exproto_gsvr.erl @@ -0,0 +1,154 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +%% The gRPC server for ConnectionAdapter +-module(emqx_exproto_gsvr). + +-behavior(emqx_exproto_v_1_connection_adapter_bhvr). + +-include("emqx_exproto.hrl"). +-include_lib("emqx/include/logger.hrl"). + +-logger_header("[ExProto gServer]"). + +-define(IS_QOS(X), (X =:= 0 orelse X =:= 1 orelse X =:= 2)). + +%% gRPC server callbacks +-export([ send/2 + , close/2 + , authenticate/2 + , start_timer/2 + , publish/2 + , subscribe/2 + , unsubscribe/2 + ]). + +%%-------------------------------------------------------------------- +%% gRPC ConnectionAdapter service +%%-------------------------------------------------------------------- + +-spec send(emqx_exproto_pb:send_bytes_request(), grpc:metadata()) + -> {ok, emqx_exproto_pb:code_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +send(Req = #{conn := Conn, bytes := Bytes}, Md) -> + ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), + {ok, response(call(Conn, {send, Bytes})), Md}. + +-spec close(emqx_exproto_pb:close_socket_request(), grpc:metadata()) + -> {ok, emqx_exproto_pb:code_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +close(Req = #{conn := Conn}, Md) -> + ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), + {ok, response(call(Conn, close)), Md}. + +-spec authenticate(emqx_exproto_pb:authenticate_request(), grpc:metadata()) + -> {ok, emqx_exproto_pb:code_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +authenticate(Req = #{conn := Conn, + password := Password, + clientinfo := ClientInfo}, Md) -> + ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), + case validate(clientinfo, ClientInfo) of + false -> + {ok, response({error, ?RESP_REQUIRED_PARAMS_MISSED}), Md}; + _ -> + {ok, response(call(Conn, {auth, ClientInfo, Password})), Md} + end. + +-spec start_timer(emqx_exproto_pb:timer_request(), grpc:metadata()) + -> {ok, emqx_exproto_pb:code_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +start_timer(Req = #{conn := Conn, type := Type, interval := Interval}, Md) + when Type =:= 'KEEPALIVE' andalso Interval > 0 -> + ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), + {ok, response(call(Conn, {start_timer, keepalive, Interval})), Md}; +start_timer(Req, Md) -> + ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), + {ok, response({error, ?RESP_PARAMS_TYPE_ERROR}), Md}. + +-spec publish(emqx_exproto_pb:publish_request(), grpc:metadata()) + -> {ok, emqx_exproto_pb:code_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +publish(Req = #{conn := Conn, topic := Topic, qos := Qos, payload := Payload}, Md) + when ?IS_QOS(Qos) -> + ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), + {ok, response(call(Conn, {publish, Topic, Qos, Payload})), Md}; + +publish(Req, Md) -> + ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), + {ok, response({error, ?RESP_PARAMS_TYPE_ERROR}), Md}. + +-spec subscribe(emqx_exproto_pb:subscribe_request(), grpc:metadata()) + -> {ok, emqx_exproto_pb:code_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +subscribe(Req = #{conn := Conn, topic := Topic, qos := Qos}, Md) + when ?IS_QOS(Qos) -> + ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), + {ok, response(call(Conn, {subscribe, Topic, Qos})), Md}; + +subscribe(Req, Md) -> + ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), + {ok, response({error, ?RESP_PARAMS_TYPE_ERROR}), Md}. + +-spec unsubscribe(emqx_exproto_pb:unsubscribe_request(), grpc:metadata()) + -> {ok, emqx_exproto_pb:code_response(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +unsubscribe(Req = #{conn := Conn, topic := Topic}, Md) -> + ?LOG(debug, "Recv ~p function with request ~0p", [?FUNCTION_NAME, Req]), + {ok, response(call(Conn, {unsubscribe, Topic})), Md}. + +%%-------------------------------------------------------------------- +%% Internal funcs +%%-------------------------------------------------------------------- + +to_pid(ConnStr) -> + list_to_pid(binary_to_list(ConnStr)). + +call(ConnStr, Req) -> + case catch to_pid(ConnStr) of + {'EXIT', {badarg, _}} -> + {error, ?RESP_PARAMS_TYPE_ERROR, + <<"The conn type error">>}; + Pid when is_pid(Pid) -> + case erlang:is_process_alive(Pid) of + true -> + emqx_exproto_conn:call(Pid, Req); + false -> + {error, ?RESP_CONN_PROCESS_NOT_ALIVE, + <<"Connection process is not alive">>} + end + end. + +%%-------------------------------------------------------------------- +%% Data types + +stringfy(Reason) -> + unicode:characters_to_binary((io_lib:format("~0p", [Reason]))). + +validate(clientinfo, M) -> + Required = [proto_name, proto_ver, clientid], + lists:all(fun(K) -> maps:is_key(K, M) end, Required). + +response(ok) -> + #{code => ?RESP_SUCCESS}; +response({error, Code, Reason}) + when ?IS_GRPC_RESULT_CODE(Code) -> + #{code => Code, message => stringfy(Reason)}; +response({error, Code}) + when ?IS_GRPC_RESULT_CODE(Code) -> + #{code => Code}; +response(Other) -> + #{code => ?RESP_UNKNOWN, message => stringfy(Other)}. diff --git a/apps/emqx_exproto/src/emqx_exproto_sup.erl b/apps/emqx_exproto/src/emqx_exproto_sup.erl index 64be2812c..d4bde316a 100644 --- a/apps/emqx_exproto/src/emqx_exproto_sup.erl +++ b/apps/emqx_exproto/src/emqx_exproto_sup.erl @@ -20,17 +20,64 @@ -export([start_link/0]). +-export([ start_grpc_server/3 + , stop_grpc_server/1 + , start_grpc_client_channel/3 + , stop_grpc_client_channel/1 + ]). + -export([init/1]). +%%-------------------------------------------------------------------- +%% APIs +%%-------------------------------------------------------------------- + start_link() -> supervisor:start_link({local, ?MODULE}, ?MODULE, []). -init([]) -> - DriverMngr = #{id => driver_mngr, - start => {emqx_exproto_driver_mngr, start_link, []}, - restart => permanent, - shutdown => 5000, - type => worker, - modules => [emqx_exproto_driver_mngr]}, - {ok, {{one_for_all, 10, 5}, [DriverMngr]}}. +-spec start_grpc_server(atom(), inet:port_number(), list()) + -> {ok, pid()} | {error, term()}. +start_grpc_server(Name, Port, SSLOptions) -> + Services = #{protos => [emqx_exproto_pb], + services => #{'emqx.exproto.v1.ConnectionAdapter' => emqx_exproto_gsvr} + }, + Options = case SSLOptions of + [] -> []; + _ -> + [{ssl_options, lists:keydelete(ssl, 1, SSLOptions)}] + end, + grpc:start_server(prefix(Name), Port, Services, Options). +-spec stop_grpc_server(atom()) -> ok. +stop_grpc_server(Name) -> + grpc:stop_server(prefix(Name)). + +-spec start_grpc_client_channel( + atom(), + uri_string:uri_string(), + grpc_client:grpc_opts()) -> {ok, pid()} | {error, term()}. +start_grpc_client_channel(Name, SvrAddr, ClientOpts) -> + grpc_client_sup:create_channel_pool(Name, SvrAddr, ClientOpts). + +-spec stop_grpc_client_channel(atom()) -> ok. +stop_grpc_client_channel(Name) -> + grpc_client_sup:stop_channel_pool(Name). + +%% @private +prefix(Name) when is_atom(Name) -> + "exproto:" ++ atom_to_list(Name); +prefix(Name) when is_binary(Name) -> + "exproto:" ++ binary_to_list(Name); +prefix(Name) when is_list(Name) -> + "exproto:" ++ Name. + +%%-------------------------------------------------------------------- +%% Supervisor callbacks +%%-------------------------------------------------------------------- + +init([]) -> + %% gRPC Client Pool + PoolSize = emqx_vm:schedulers() * 2, + Pool = emqx_pool_sup:spec([exproto_gcli_pool, hash, PoolSize, + {emqx_exproto_gcli, start_link, []}]), + {ok, {{one_for_one, 10, 5}, [Pool]}}. diff --git a/apps/emqx_exproto/src/emqx_exproto_types.erl b/apps/emqx_exproto/src/emqx_exproto_types.erl deleted file mode 100644 index aefaae07e..000000000 --- a/apps/emqx_exproto/src/emqx_exproto_types.erl +++ /dev/null @@ -1,179 +0,0 @@ -%%-------------------------------------------------------------------- -%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. -%% -%% Licensed under the Apache License, Version 2.0 (the "License"); -%% you may not use this file except in compliance with the License. -%% You may obtain a copy of the License at -%% -%% http://www.apache.org/licenses/LICENSE-2.0 -%% -%% Unless required by applicable law or agreed to in writing, software -%% distributed under the License is distributed on an "AS IS" BASIS, -%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -%% See the License for the specific language governing permissions and -%% limitations under the License. -%%-------------------------------------------------------------------- - --module(emqx_exproto_types). - --include_lib("emqx/include/emqx.hrl"). --include_lib("emqx/include/types.hrl"). - --import(proplists, [get_value/2]). - --export([ parse/2 - , serialize/2 - ]). - --type(clientinfo() :: #{ proto_name := maybe(binary()) - , proto_ver := maybe(non_neg_integer()) - , clientid := maybe(binary()) - , username := maybe(binary()) - , mountpoint := maybe(binary()) - , keepalive := maybe(non_neg_integer()) - }). - --type(conninfo() :: #{ socktype := tcp | tls | udp | dtls - , peername := emqx_types:peername() - , sockname := emqx_types:sockname() - , peercert := nossl | binary() | list() - , conn_mod := atom() - }). - --export_type([conninfo/0, clientinfo/0]). - --define(UP_DATA_SCHEMA_CLIENTINFO, - [ {proto_name, optional, binary} - , {proto_ver, optional, [integer, binary]} - , {clientid, optional, binary} - , {username, optional, binary} - , {mountpoint, optional, binary} - , {keepalive, optional, integer} - ]). - --define(UP_DATA_SCHEMA_MESSAGE, - [ {id, {optional, fun emqx_guid:gen/0}, binary} - , {qos, required, [{enum, [0, 1, 2]}]} - , {from, optional, [binary, atom]} - , {topic, required, binary} - , {payload, required, binary} - , {timestamp, {optional, fun() -> erlang:system_time(millisecond) end}, integer} - ]). - -%%-------------------------------------------------------------------- -%% APIs -%%-------------------------------------------------------------------- - --spec(parse(clientinfo | message, list()) - -> {error, any()} - | clientinfo() - | emqx_types:message()). -parse(clientinfo, Params) -> - to_map(do_parsing(?UP_DATA_SCHEMA_CLIENTINFO, Params)); - -parse(message, Params) -> - to_message(do_parsing(?UP_DATA_SCHEMA_MESSAGE, Params)); - -parse(Type, _) -> - {error, {unkown_type, Type}}. - -%% @private -to_map(Err = {error, _}) -> - Err; -to_map(Ls) -> - maps:from_list(Ls). - -%% @private -to_message(Err = {error, _}) -> - Err; -to_message(Ls) -> - #message{ - id = get_value(id, Ls), - qos = get_value(qos, Ls), - from = get_value(from, Ls), - topic = get_value(topic, Ls), - payload = get_value(payload, Ls), - timestamp = get_value(timestamp, Ls)}. - --spec(serialize(Type, Struct) - -> {error, any()} - | [{atom(), any()}] - when Type :: conninfo | message, - Struct :: conninfo() | emqx_types:message()). -serialize(conninfo, #{socktype := A1, - peername := A2, - sockname := A3, - peercert := Peercert - }) -> - [{socktype, A1}, - {peername, A2}, - {sockname, A3}, - {peercert, do_serializing(peercert, Peercert)}]; - -serialize(message, Msg) -> - [{id, emqx_message:id(Msg)}, - {qos, emqx_message:qos(Msg)}, - {from, emqx_message:from(Msg)}, - {topic, emqx_message:topic(Msg)}, - {payload, emqx_message:payload(Msg)}, - {timestamp, emqx_message:timestamp(Msg)}]; - -serialize(Type, _) -> - {error, {unkown_type, Type}}. - -%%-------------------------------------------------------------------- -%% Internal funcs -%%-------------------------------------------------------------------- - -do_parsing(Schema, Params) -> - try do_parsing(Schema, Params, []) - catch - throw:{badarg, Reason} -> {error, Reason} - end. -do_parsing([], _Params, Acc) -> - lists:reverse(Acc); -do_parsing([Indictor = {Key, _Optional, Type} | More], Params, Acc) -> - Value = case get_value(Key, Params) of - undefined -> do_generating(Indictor); - InParam -> do_typing(Key, InParam, Type) - end, - do_parsing(More, Params, [{Key, Value} | Acc]). - -%% @private -do_generating({Key, required, _}) -> - throw({badarg, errmsg("~s is required", [Key])}); -do_generating({_, optional, _}) -> - undefined; -do_generating({_, {_, Generator}, _}) when is_function(Generator) -> - Generator(); -do_generating({_, {_, Default}, _}) -> - Default. - -%% @private -do_typing(Key, InParam, Types) when is_list(Types) -> - case length(lists:filter(fun(T) -> is_x_type(InParam, T) end, Types)) of - 0 -> - throw({badarg, errmsg("~s: value ~p data type is not validate to ~p", [Key, InParam, Types])}); - _ -> - InParam - end; -do_typing(Key, InParam, Type) -> - do_typing(Key, InParam, [Type]). - -% @private -is_x_type(P, atom) when is_atom(P) -> true; -is_x_type(P, binary) when is_binary(P) -> true; -is_x_type(P, integer) when is_integer(P) -> true; -is_x_type(P, {enum, Ls}) -> - lists:member(P, Ls); -is_x_type(_, _) -> false. - -do_serializing(peercert, nossl) -> - nossl; -do_serializing(peercert, Peercert) -> - [{dn, esockd_peercert:subject(Peercert)}, - {cn, esockd_peercert:common_name(Peercert)}]. - -errmsg(Fmt, Args) -> - lists:flatten(io_lib:format(Fmt, Args)). - diff --git a/apps/emqx_exproto/test/emqx_exproto_SUITE.erl b/apps/emqx_exproto/test/emqx_exproto_SUITE.erl index 5a9d4b830..dc6a25c06 100644 --- a/apps/emqx_exproto/test/emqx_exproto_SUITE.erl +++ b/apps/emqx_exproto/test/emqx_exproto_SUITE.erl @@ -19,7 +19,20 @@ -compile(export_all). -compile(nowarn_export_all). +-import(emqx_exproto_echo_svr, + [ frame_connect/2 + , frame_connack/1 + , frame_publish/3 + , frame_puback/1 + , frame_subscribe/2 + , frame_suback/1 + , frame_unsubscribe/1 + , frame_unsuback/1 + , frame_disconnect/0 + ]). + -include_lib("emqx/include/emqx.hrl"). +-include_lib("emqx/include/emqx_mqtt.hrl"). -define(TCPOPTS, [binary, {active, false}]). -define(DTLSOPTS, [binary, {active, false}, {protocol, dtls}]). @@ -37,48 +50,38 @@ groups() -> %% @private metrics() -> - [ list_to_atom(X ++ "_" ++ Y) - || X <- ["python3", "java"], Y <- ["tcp", "ssl", "udp", "dtls"]]. + [tcp, ssl, udp, dtls]. -init_per_group(GrpName, Config) -> - [Lang, LisType] = [list_to_atom(X) || X <- string:tokens(atom_to_list(GrpName), "_")], - put(grpname, {Lang, LisType}), +init_per_group(GrpName, Cfg) -> + put(grpname, GrpName), + Svrs = emqx_exproto_echo_svr:start(), emqx_ct_helpers:start_apps([emqx_exproto], fun set_sepecial_cfg/1), - [{driver_type, Lang}, - {listener_type, LisType} | Config]. + emqx_logger:set_log_level(debug), + [{servers, Svrs}, {listener_type, GrpName} | Cfg]. -end_per_group(_, _) -> - emqx_ct_helpers:stop_apps([emqx_exproto]). +end_per_group(_, Cfg) -> + emqx_ct_helpers:stop_apps([emqx_exproto]), + emqx_exproto_echo_svr:stop(proplists:get_value(servers, Cfg)). set_sepecial_cfg(emqx_exproto) -> - {Lang, LisType} = get(grpname), - Path = emqx_ct_helpers:deps_path(emqx_exproto, "example/"), + LisType = get(grpname), Listeners = application:get_env(emqx_exproto, listeners, []), - Driver = compile(Lang, Path), SockOpts = socketopts(LisType), UpgradeOpts = fun(Opts) -> - Opts1 = lists:keydelete(driver, 1, Opts), - Opts2 = lists:keydelete(tcp_options, 1, Opts1), + Opts2 = lists:keydelete(tcp_options, 1, Opts), Opts3 = lists:keydelete(ssl_options, 1, Opts2), Opts4 = lists:keydelete(udp_options, 1, Opts3), Opts5 = lists:keydelete(dtls_options, 1, Opts4), - Driver ++ SockOpts ++ Opts5 + SockOpts ++ Opts5 end, NListeners = [{Proto, LisType, LisOn, UpgradeOpts(Opts)} || {Proto, _Type, LisOn, Opts} <- Listeners], application:set_env(emqx_exproto, listeners, NListeners); -set_sepecial_cfg(_App) -> +set_sepecial_cfg(emqx) -> + application:set_env(emqx, allow_anonymous, true), + application:set_env(emqx, enable_acl_cache, false), ok. -compile(java, Path) -> - ErlPortJar = emqx_ct_helpers:deps_path(erlport, "priv/java/_pkgs/erlport.jar"), - ct:pal(os:cmd(lists:concat(["cd ", Path, " && ", - "rm -rf Main.class State.class && ", - "javac -cp ", ErlPortJar, " Main.java"]))), - [{driver, [{type, java}, {path, Path}, {cbm, 'Main'}]}]; -compile(python3, Path) -> - [{driver, [{type, python3}, {path, Path}, {cbm, main}]}]. - %%-------------------------------------------------------------------- %% Tests cases %%-------------------------------------------------------------------- @@ -86,24 +89,263 @@ compile(python3, Path) -> t_start_stop(_) -> ok. -t_echo(Cfg) -> +t_mountpoint_echo(Cfg) -> SockType = proplists:get_value(listener_type, Cfg), - Bin = rand_bytes(), - Sock = open(SockType), - send(Sock, Bin), + Client = #{proto_name => <<"demo">>, + proto_ver => <<"v0.1">>, + clientid => <<"test_client_1">>, + mountpoint => <<"ct/">> + }, + Password = <<"123456">>, - {ok, Bin} = recv(Sock, byte_size(Bin), 5000), + ConnBin = frame_connect(Client, Password), + ConnAckBin = frame_connack(0), - %% pubsub echo - emqx:subscribe(<<"t/#">>), - emqx:publish(emqx_message:make(<<"t/dn">>, <<"echo">>)), - First = receive {_, _, X} -> X#message.payload end, - First = receive {_, _, Y} -> Y#message.payload end, + send(Sock, ConnBin), + {ok, ConnAckBin} = recv(Sock, 5000), + SubBin = frame_subscribe(<<"t/#">>, 1), + SubAckBin = frame_suback(0), + + send(Sock, SubBin), + {ok, SubAckBin} = recv(Sock, 5000), + + emqx:publish(emqx_message:make(<<"ct/t/dn">>, <<"echo">>)), + PubBin1 = frame_publish(<<"t/dn">>, 0, <<"echo">>), + {ok, PubBin1} = recv(Sock, 5000), + + PubBin2 = frame_publish(<<"t/up">>, 0, <<"echo">>), + PubAckBin = frame_puback(0), + + emqx:subscribe(<<"ct/t/up">>), + + send(Sock, PubBin2), + {ok, PubAckBin} = recv(Sock, 5000), + + receive + {deliver, _, _} -> ok + after 1000 -> + error(echo_not_running) + end, close(Sock). +t_auth_deny(Cfg) -> + SockType = proplists:get_value(listener_type, Cfg), + Sock = open(SockType), + + Client = #{proto_name => <<"demo">>, + proto_ver => <<"v0.1">>, + clientid => <<"test_client_1">> + }, + Password = <<"123456">>, + + ok = meck:new(emqx_access_control, [passthrough, no_history, no_link]), + ok = meck:expect(emqx_access_control, authenticate, + fun(_) -> {error, ?RC_NOT_AUTHORIZED} end), + + ConnBin = frame_connect(Client, Password), + ConnAckBin = frame_connack(1), + + send(Sock, ConnBin), + {ok, ConnAckBin} = recv(Sock, 5000), + + SockType =/= udp andalso begin + {error, closed} = recv(Sock, 5000) + end, + meck:unload([emqx_access_control]). + +t_acl_deny(Cfg) -> + SockType = proplists:get_value(listener_type, Cfg), + Sock = open(SockType), + + Client = #{proto_name => <<"demo">>, + proto_ver => <<"v0.1">>, + clientid => <<"test_client_1">> + }, + Password = <<"123456">>, + + ok = meck:new(emqx_access_control, [passthrough, no_history, no_link]), + ok = meck:expect(emqx_access_control, check_acl, fun(_, _, _) -> deny end), + + ConnBin = frame_connect(Client, Password), + ConnAckBin = frame_connack(0), + + send(Sock, ConnBin), + {ok, ConnAckBin} = recv(Sock, 5000), + + SubBin = frame_subscribe(<<"t/#">>, 1), + SubAckBin = frame_suback(1), + + send(Sock, SubBin), + {ok, SubAckBin} = recv(Sock, 5000), + + emqx:publish(emqx_message:make(<<"t/dn">>, <<"echo">>)), + + PubBin = frame_publish(<<"t/dn">>, 0, <<"echo">>), + PubBinFailedAck = frame_puback(1), + PubBinSuccesAck = frame_puback(0), + + send(Sock, PubBin), + {ok, PubBinFailedAck} = recv(Sock, 5000), + + meck:unload([emqx_access_control]), + + send(Sock, PubBin), + {ok, PubBinSuccesAck} = recv(Sock, 5000), + close(Sock). + +t_keepalive_timeout(Cfg) -> + SockType = proplists:get_value(listener_type, Cfg), + Sock = open(SockType), + + Client = #{proto_name => <<"demo">>, + proto_ver => <<"v0.1">>, + clientid => <<"test_client_1">>, + keepalive => 2 + }, + Password = <<"123456">>, + + ConnBin = frame_connect(Client, Password), + ConnAckBin = frame_connack(0), + + send(Sock, ConnBin), + {ok, ConnAckBin} = recv(Sock, 5000), + + DisconnectBin = frame_disconnect(), + {ok, DisconnectBin} = recv(Sock, 10000), + + SockType =/= udp andalso begin + {error, closed} = recv(Sock, 5000) + end, ok. + +t_hook_connected_disconnected(Cfg) -> + SockType = proplists:get_value(listener_type, Cfg), + Sock = open(SockType), + + Client = #{proto_name => <<"demo">>, + proto_ver => <<"v0.1">>, + clientid => <<"test_client_1">> + }, + Password = <<"123456">>, + + ConnBin = frame_connect(Client, Password), + ConnAckBin = frame_connack(0), + + Parent = self(), + HookFun1 = fun(_, _) -> Parent ! connected, ok end, + HookFun2 = fun(_, _, _) -> Parent ! disconnected, ok end, + emqx:hook('client.connected', HookFun1), + emqx:hook('client.disconnected', HookFun2), + + + send(Sock, ConnBin), + {ok, ConnAckBin} = recv(Sock, 5000), + + receive + connected -> ok + after 1000 -> + error(hook_is_not_running) + end, + + DisconnectBin = frame_disconnect(), + send(Sock, DisconnectBin), + + receive + disconnected -> ok + after 1000 -> + error(hook_is_not_running) + end, + + SockType =/= udp andalso begin + {error, closed} = recv(Sock, 5000) + end, + emqx:unhook('client.connected', HookFun1), + emqx:unhook('client.disconnected', HookFun2). + +t_hook_session_subscribed_unsubscribed(Cfg) -> + SockType = proplists:get_value(listener_type, Cfg), + Sock = open(SockType), + + Client = #{proto_name => <<"demo">>, + proto_ver => <<"v0.1">>, + clientid => <<"test_client_1">> + }, + Password = <<"123456">>, + + ConnBin = frame_connect(Client, Password), + ConnAckBin = frame_connack(0), + + send(Sock, ConnBin), + {ok, ConnAckBin} = recv(Sock, 5000), + + Parent = self(), + HookFun1 = fun(_, _, _) -> Parent ! subscribed, ok end, + HookFun2 = fun(_, _, _) -> Parent ! unsubscribed, ok end, + emqx:hook('session.subscribed', HookFun1), + emqx:hook('session.unsubscribed', HookFun2), + + SubBin = frame_subscribe(<<"t/#">>, 1), + SubAckBin = frame_suback(0), + + send(Sock, SubBin), + {ok, SubAckBin} = recv(Sock, 5000), + + receive + subscribed -> ok + after 1000 -> + error(hook_is_not_running) + end, + + UnsubBin = frame_unsubscribe(<<"t/#">>), + UnsubAckBin = frame_unsuback(0), + + send(Sock, UnsubBin), + {ok, UnsubAckBin} = recv(Sock, 5000), + + receive + unsubscribed -> ok + after 1000 -> + error(hook_is_not_running) + end, + + close(Sock), + emqx:unhook('session.subscribed', HookFun1), + emqx:unhook('session.unsubscribed', HookFun2). + +t_hook_message_delivered(Cfg) -> + SockType = proplists:get_value(listener_type, Cfg), + Sock = open(SockType), + + Client = #{proto_name => <<"demo">>, + proto_ver => <<"v0.1">>, + clientid => <<"test_client_1">> + }, + Password = <<"123456">>, + + ConnBin = frame_connect(Client, Password), + ConnAckBin = frame_connack(0), + + send(Sock, ConnBin), + {ok, ConnAckBin} = recv(Sock, 5000), + + SubBin = frame_subscribe(<<"t/#">>, 1), + SubAckBin = frame_suback(0), + + send(Sock, SubBin), + {ok, SubAckBin} = recv(Sock, 5000), + + HookFun1 = fun(_, Msg) -> {ok, Msg#message{payload = <<"2">>}} end, + emqx:hook('message.delivered', HookFun1), + + emqx:publish(emqx_message:make(<<"t/dn">>, <<"1">>)), + PubBin1 = frame_publish(<<"t/dn">>, 0, <<"2">>), + {ok, PubBin1} = recv(Sock, 5000), + + close(Sock), + emqx:unhook('message.delivered', HookFun1). + %%-------------------------------------------------------------------- %% Utils @@ -137,15 +379,15 @@ send({ssl, Sock}, Bin) -> send({dtls, Sock}, Bin) -> ssl:send(Sock, Bin). -recv({tcp, Sock}, Size, Ts) -> - gen_tcp:recv(Sock, Size, Ts); -recv({udp, Sock}, Size, Ts) -> - {ok, {_, _, Bin}} = gen_udp:recv(Sock, Size, Ts), +recv({tcp, Sock}, Ts) -> + gen_tcp:recv(Sock, 0, Ts); +recv({udp, Sock}, Ts) -> + {ok, {_, _, Bin}} = gen_udp:recv(Sock, 0, Ts), {ok, Bin}; -recv({ssl, Sock}, Size, Ts) -> - ssl:recv(Sock, Size, Ts); -recv({dtls, Sock}, Size, Ts) -> - ssl:recv(Sock, Size, Ts). +recv({ssl, Sock}, Ts) -> + ssl:recv(Sock, 0, Ts); +recv({dtls, Sock}, Ts) -> + ssl:recv(Sock, 0, Ts). close({tcp, Sock}) -> gen_tcp:close(Sock); diff --git a/apps/emqx_exproto/test/emqx_exproto_echo_svr.erl b/apps/emqx_exproto/test/emqx_exproto_echo_svr.erl new file mode 100644 index 000000000..3742a29a8 --- /dev/null +++ b/apps/emqx_exproto/test/emqx_exproto_echo_svr.erl @@ -0,0 +1,249 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_exproto_echo_svr). + +-behavior(emqx_exproto_v_1_connection_handler_bhvr). + +-export([ start/0 + , stop/1 + ]). + +-export([ frame_connect/2 + , frame_connack/1 + , frame_publish/3 + , frame_puback/1 + , frame_subscribe/2 + , frame_suback/1 + , frame_unsubscribe/1 + , frame_unsuback/1 + , frame_disconnect/0 + ]). + +-export([ on_socket_created/2 + , on_received_bytes/2 + , on_socket_closed/2 + , on_timer_timeout/2 + , on_received_messages/2 + ]). + +-define(HTTP, #{grpc_opts => #{service_protos => [emqx_exproto_pb], + services => #{'emqx.exproto.v1.ConnectionHandler' => ?MODULE}}, + listen_opts => #{port => 9001, + socket_options => []}, + pool_opts => #{size => 8}, + transport_opts => #{ssl => false}}). + +-define(CLIENT, emqx_exproto_v_1_connection_adapter_client). +-define(send(Req), ?CLIENT:send(Req, #{channel => ct_test_channel})). +-define(close(Req), ?CLIENT:close(Req, #{channel => ct_test_channel})). +-define(authenticate(Req), ?CLIENT:authenticate(Req, #{channel => ct_test_channel})). +-define(start_timer(Req), ?CLIENT:start_timer(Req, #{channel => ct_test_channel})). +-define(publish(Req), ?CLIENT:publish(Req, #{channel => ct_test_channel})). +-define(subscribe(Req), ?CLIENT:subscribe(Req, #{channel => ct_test_channel})). +-define(unsubscribe(Req), ?CLIENT:unsubscribe(Req, #{channel => ct_test_channel})). + +-define(TYPE_CONNECT, 1). +-define(TYPE_CONNACK, 2). +-define(TYPE_PUBLISH, 3). +-define(TYPE_PUBACK, 4). +-define(TYPE_SUBSCRIBE, 5). +-define(TYPE_SUBACK, 6). +-define(TYPE_UNSUBSCRIBE, 7). +-define(TYPE_UNSUBACK, 8). +-define(TYPE_DISCONNECT, 9). + +%%-------------------------------------------------------------------- +%% APIs +%%-------------------------------------------------------------------- + +start() -> + application:ensure_all_started(grpc), + [start_channel(), start_server()]. + +start_channel() -> + grpc_client_sup:create_channel_pool(ct_test_channel, "http://127.0.0.1:9100", #{}). + +start_server() -> + Services = #{protos => [emqx_exproto_pb], + services => #{'emqx.exproto.v1.ConnectionHandler' => ?MODULE} + }, + Options = [], + grpc:start_server(?MODULE, 9001, Services, Options). + +stop([_ChannPid, _SvrPid]) -> + grpc:stop_server(?MODULE), + grpc_client_sup:stop_channel_pool(ct_test_channel). + +%%-------------------------------------------------------------------- +%% Protocol Adapter callbacks +%%-------------------------------------------------------------------- + +-spec on_socket_created(emqx_exproto_pb:socket_created_request(), grpc:metadata()) + -> {ok, emqx_exproto_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_socket_created(Req, Md) -> + io:format("~p: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_socket_closed(emqx_exproto_pb:socket_closed_request(), grpc:metadata()) + -> {ok, emqx_exproto_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_socket_closed(Req, Md) -> + io:format("~p: ~0p~n", [?FUNCTION_NAME, Req]), + {ok, #{}, Md}. + +-spec on_received_bytes(emqx_exproto_pb:received_bytes_request(), grpc:metadata()) + -> {ok, emqx_exproto_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_received_bytes(Req = #{conn := Conn, bytes := Bytes}, Md) -> + io:format("~p: ~0p~n", [?FUNCTION_NAME, Req]), + #{<<"type">> := Type} = Params = emqx_json:decode(Bytes, [return_maps]), + _ = handle_in(Conn, Type, Params), + {ok, #{}, Md}. + +-spec on_timer_timeout(emqx_exproto_pb:timer_timeout_request(), grpc:metadata()) + -> {ok, emqx_exproto_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_timer_timeout(Req = #{conn := Conn, type := 'KEEPALIVE'}, Md) -> + io:format("~p: ~0p~n", [?FUNCTION_NAME, Req]), + handle_out(Conn, ?TYPE_DISCONNECT), + ?close(#{conn => Conn}), + {ok, #{}, Md}. + +-spec on_received_messages(emqx_exproto_pb:received_messages_request(), grpc:metadata()) + -> {ok, emqx_exproto_pb:empty_success(), grpc:metadata()} + | {error, grpc_cowboy_h:error_response()}. +on_received_messages(Req = #{conn := Conn, messages := Messages}, Md) -> + io:format("~p: ~0p~n", [?FUNCTION_NAME, Req]), + lists:foreach(fun(Message) -> + handle_out(Conn, ?TYPE_PUBLISH, Message) + end, Messages), + {ok, #{}, Md}. + +%%-------------------------------------------------------------------- +%% The Protocol Example: +%% CONN: +%% {"type": 1, "clientinfo": {...}} +%% +%% CONNACK: +%% {"type": 2, "code": 0} +%% +%% PUBLISH: +%% {"type": 3, "topic": "xxx", "payload": "", "qos": 0} +%% +%% PUBACK: +%% {"type": 4, "code": 0} +%% +%% SUBSCRIBE: +%% {"type": 5, "topic": "xxx", "qos": 1} +%% +%% SUBACK: +%% {"type": 6, "code": 0} +%% +%% DISCONNECT: +%% {"type": 7, "code": 1} +%%-------------------------------------------------------------------- + +handle_in(Conn, ?TYPE_CONNECT, #{<<"clientinfo">> := ClientInfo, <<"password">> := Password}) -> + NClientInfo = maps:from_list([{binary_to_atom(K, utf8), V} || {K, V} <- maps:to_list(ClientInfo)]), + case ?authenticate(#{conn => Conn, clientinfo => NClientInfo, password => Password}) of + {ok, #{code := 'SUCCESS'}, _} -> + case maps:get(keepalive, NClientInfo, 0) of + 0 -> ok; + Intv -> + io:format("Try call start_timer with ~ps", [Intv]), + ?start_timer(#{conn => Conn, type => 'KEEPALIVE', interval => Intv}) + end, + handle_out(Conn, ?TYPE_CONNACK, 0); + _ -> + handle_out(Conn, ?TYPE_CONNACK, 1), + ?close(#{conn => Conn}) + end; +handle_in(Conn, ?TYPE_PUBLISH, #{<<"topic">> := Topic, + <<"qos">> := Qos, + <<"payload">> := Payload}) -> + case ?publish(#{conn => Conn, topic => Topic, qos => Qos, payload => Payload}) of + {ok, #{code := 'SUCCESS'}, _} -> + handle_out(Conn, ?TYPE_PUBACK, 0); + _ -> + handle_out(Conn, ?TYPE_PUBACK, 1) + end; +handle_in(Conn, ?TYPE_SUBSCRIBE, #{<<"qos">> := Qos, <<"topic">> := Topic}) -> + case ?subscribe(#{conn => Conn, topic => Topic, qos => Qos}) of + {ok, #{code := 'SUCCESS'}, _} -> + handle_out(Conn, ?TYPE_SUBACK, 0); + _ -> + handle_out(Conn, ?TYPE_SUBACK, 1) + end; +handle_in(Conn, ?TYPE_UNSUBSCRIBE, #{<<"topic">> := Topic}) -> + case ?unsubscribe(#{conn => Conn, topic => Topic}) of + {ok, #{code := 'SUCCESS'}, _} -> + handle_out(Conn, ?TYPE_UNSUBACK, 0); + _ -> + handle_out(Conn, ?TYPE_UNSUBACK, 1) + end; + +handle_in(Conn, ?TYPE_DISCONNECT, _) -> + ?close(#{conn => Conn}). + +handle_out(Conn, ?TYPE_CONNACK, Code) -> + ?send(#{conn => Conn, bytes => frame_connack(Code)}); +handle_out(Conn, ?TYPE_PUBACK, Code) -> + ?send(#{conn => Conn, bytes => frame_puback(Code)}); +handle_out(Conn, ?TYPE_SUBACK, Code) -> + ?send(#{conn => Conn, bytes => frame_suback(Code)}); +handle_out(Conn, ?TYPE_UNSUBACK, Code) -> + ?send(#{conn => Conn, bytes => frame_unsuback(Code)}); +handle_out(Conn, ?TYPE_PUBLISH, #{qos := Qos, topic := Topic, payload := Payload}) -> + ?send(#{conn => Conn, bytes => frame_publish(Topic, Qos, Payload)}). + +handle_out(Conn, ?TYPE_DISCONNECT) -> + ?send(#{conn => Conn, bytes => frame_disconnect()}). + +%%-------------------------------------------------------------------- +%% Frame + +frame_connect(ClientInfo, Password) -> + emqx_json:encode(#{type => ?TYPE_CONNECT, + clientinfo => ClientInfo, + password => Password}). +frame_connack(Code) -> + emqx_json:encode(#{type => ?TYPE_CONNACK, code => Code}). + +frame_publish(Topic, Qos, Payload) -> + emqx_json:encode(#{type => ?TYPE_PUBLISH, + topic => Topic, + qos => Qos, + payload => Payload}). + +frame_puback(Code) -> + emqx_json:encode(#{type => ?TYPE_PUBACK, code => Code}). + +frame_subscribe(Topic, Qos) -> + emqx_json:encode(#{type => ?TYPE_SUBSCRIBE, topic => Topic, qos => Qos}). + +frame_suback(Code) -> + emqx_json:encode(#{type => ?TYPE_SUBACK, code => Code}). + +frame_unsubscribe(Topic) -> + emqx_json:encode(#{type => ?TYPE_UNSUBSCRIBE, topic => Topic}). + +frame_unsuback(Code) -> + emqx_json:encode(#{type => ?TYPE_UNSUBACK, code => Code}). + +frame_disconnect() -> + emqx_json:encode(#{type => ?TYPE_DISCONNECT}). diff --git a/apps/emqx_prometheus/rebar.config b/apps/emqx_prometheus/rebar.config index a47380479..6e61fd467 100644 --- a/apps/emqx_prometheus/rebar.config +++ b/apps/emqx_prometheus/rebar.config @@ -1,6 +1,6 @@ {deps, [{prometheus, {git, "https://github.com/emqx/prometheus.erl", {tag, "v3.1.1"}}}, - {minirest, {git, "https://github.com/emqx/minirest", {tag, "0.3.1"}}} + {minirest, {git, "https://github.com/emqx/minirest", {tag, "0.3.2"}}} ]}. {edoc_opts, [{preprocess, true}]}. diff --git a/apps/emqx_retainer/rebar.config b/apps/emqx_retainer/rebar.config index d53cedc61..9557780e8 100644 --- a/apps/emqx_retainer/rebar.config +++ b/apps/emqx_retainer/rebar.config @@ -19,6 +19,6 @@ [{test, [{deps, [{emqx_ct_helpers, {git, "https://github.com/emqx/emqx-ct-helpers", {tag, "1.2.2"}}}, - {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.0"}}}]} + {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.3"}}}]} ]} ]}. diff --git a/apps/emqx_retainer/src/emqx_retainer.erl b/apps/emqx_retainer/src/emqx_retainer.erl index 3531d7ecb..3d8206077 100644 --- a/apps/emqx_retainer/src/emqx_retainer.erl +++ b/apps/emqx_retainer/src/emqx_retainer.erl @@ -37,6 +37,9 @@ -export([clean/1]). +%% for emqx_pool task func +-export([dispatch/2]). + %% gen_server callbacks -export([ init/1 , handle_call/3 @@ -64,7 +67,7 @@ on_session_subscribed(_, _, #{share := ShareName}) when ShareName =/= undefined ok; on_session_subscribed(_, Topic, #{rh := Rh, is_new := IsNew}) -> case Rh =:= 0 orelse (Rh =:= 1 andalso IsNew) of - true -> emqx_pool:async_submit(fun dispatch/2, [self(), Topic]); + true -> emqx_pool:async_submit(fun ?MODULE:dispatch/2, [self(), Topic]); _ -> ok end. diff --git a/apps/emqx_rule_engine/include/rule_actions.hrl b/apps/emqx_rule_engine/include/rule_actions.hrl new file mode 100644 index 000000000..a1532da9e --- /dev/null +++ b/apps/emqx_rule_engine/include/rule_actions.hrl @@ -0,0 +1,11 @@ +-compile({parse_transform, emqx_rule_actions_trans}). + +-type selected_data() :: map(). +-type env_vars() :: map(). +-type bindings() :: list(#{atom() => term()}). + +-define(BINDING_KEYS, '__bindings__'). + +-define(bound_v(Key, ENVS0), + maps:get(Key, + maps:get(?BINDING_KEYS, ENVS0, #{}))). diff --git a/apps/emqx_rule_engine/include/rule_engine.hrl b/apps/emqx_rule_engine/include/rule_engine.hrl index 9d5db258b..9d01fefad 100644 --- a/apps/emqx_rule_engine/include/rule_engine.hrl +++ b/apps/emqx_rule_engine/include/rule_engine.hrl @@ -111,7 +111,8 @@ -record(action_instance_params, { id :: action_instance_id() , params :: #{} %% the params got after initializing the action - , apply :: fun((Data::map(), Envs::map()) -> any()) %% the func got after initializing the action + , apply :: fun((Data::map(), Envs::map()) -> any()) + | {M::module(), F::atom(), Args::list()} %% the func got after initializing the action }). %% Arithmetic operators diff --git a/apps/emqx_rule_engine/rebar.config b/apps/emqx_rule_engine/rebar.config index b3e084c56..0a9686593 100644 --- a/apps/emqx_rule_engine/rebar.config +++ b/apps/emqx_rule_engine/rebar.config @@ -1,7 +1,7 @@ {minimum_otp_vsn, "21.0"}. {deps, - [{minirest, {git, "https://github.com/emqx/minirest", {tag, "0.3.1"}}}, + [{minirest, {git, "https://github.com/emqx/minirest", {tag, "0.3.2"}}}, {rulesql, {git, "https://github.com/emqx/rulesql", {tag, "0.1.2"}}}, {getopt, "1.0.1"} ]}. diff --git a/apps/emqx_rule_engine/src/emqx_rule_actions.erl b/apps/emqx_rule_engine/src/emqx_rule_actions.erl index e024f0cfa..b49a0b2c1 100644 --- a/apps/emqx_rule_engine/src/emqx_rule_actions.erl +++ b/apps/emqx_rule_engine/src/emqx_rule_actions.erl @@ -18,6 +18,7 @@ -module(emqx_rule_actions). -include("rule_engine.hrl"). +-include("rule_actions.hrl"). -include_lib("emqx/include/emqx.hrl"). -include_lib("emqx/include/logger.hrl"). @@ -47,7 +48,7 @@ order => 3, type => string, input => textarea, - required => true, + required => false, default => <<"${payload}">>, title => #{en => <<"Payload Template">>, zh => <<"消息内容模板"/utf8>>}, @@ -84,7 +85,7 @@ category => debug, for => '$any', types => [], - create => on_action_do_nothing, + create => on_action_create_do_nothing, params => #{}, title => #{en => <<"Do Nothing (debug)">>, zh => <<"空动作 (调试)"/utf8>>}, @@ -92,79 +93,120 @@ zh => <<"此动作什么都不做,并且不会失败 (用以调试)"/utf8>>} }). --type(action_fun() :: fun((SelectedData::map(), Envs::map()) -> Result::any())). - --export_type([action_fun/0]). - -export([on_resource_create/2]). +%% callbacks for rule engine -export([ on_action_create_inspect/2 , on_action_create_republish/2 + , on_action_create_do_nothing/2 + ]). + +-export([ on_action_inspect/2 + , on_action_republish/2 , on_action_do_nothing/2 ]). -%%------------------------------------------------------------------------------ -%% Default actions for the Rule Engine -%%------------------------------------------------------------------------------ -spec(on_resource_create(binary(), map()) -> map()). on_resource_create(_Name, Conf) -> Conf. --spec(on_action_create_inspect(action_instance_id(), Params :: map()) -> action_fun()). -on_action_create_inspect(_Id, Params) -> - fun(Selected, Envs) -> - io:format("[inspect]~n" - "\tSelected Data: ~p~n" - "\tEnvs: ~p~n" - "\tAction Init Params: ~p~n", [Selected, Envs, Params]) - end. +%%------------------------------------------------------------------------------ +%% Action 'inspect' +%%------------------------------------------------------------------------------ +-spec on_action_create_inspect(action_instance_id(), Params :: map()) + -> NewParams :: map(). +on_action_create_inspect(Id, Params) -> + Params. -%% A Demo Action. --spec(on_action_create_republish(action_instance_id(), #{binary() := emqx_topic:topic()}) - -> action_fun()). -on_action_create_republish(Id, #{<<"target_topic">> := TargetTopic, <<"target_qos">> := TargetQoS, <<"payload_tmpl">> := PayloadTmpl}) -> +-spec on_action_inspect(selected_data(), env_vars()) -> any(). +on_action_inspect(Selected, Envs) -> + io:format("[inspect]~n" + "\tSelected Data: ~p~n" + "\tEnvs: ~p~n" + "\tAction Init Params: ~p~n", [Selected, Envs, ?bound_v('Params', Envs)]), + emqx_rule_metrics:inc_actions_success(?bound_v('Id', Envs)). + + +%%------------------------------------------------------------------------------ +%% Action 'republish' +%%------------------------------------------------------------------------------ +-spec on_action_create_republish(action_instance_id(), Params :: map()) + -> NewParams :: map(). +on_action_create_republish(Id, Params = #{ + <<"target_topic">> := TargetTopic, + <<"target_qos">> := TargetQoS, + <<"payload_tmpl">> := PayloadTmpl + }) -> TopicTks = emqx_rule_utils:preproc_tmpl(TargetTopic), PayloadTks = emqx_rule_utils:preproc_tmpl(PayloadTmpl), - fun (_Selected, Envs = #{headers := #{republish_by := ActId}, - topic := Topic}) when ActId =:= Id -> - ?LOG(error, "[republish] recursively republish detected, msg topic: ~p, target topic: ~p", - [Topic, TargetTopic]), - error({recursive_republish, Envs}); - (Selected, _Envs = #{qos := QoS, flags := Flags, timestamp := Timestamp}) -> - ?LOG(debug, "[republish] republish to: ~p, Payload: ~p", - [TargetTopic, Selected]), - increase_and_publish( - #message{ - id = emqx_guid:gen(), - qos = if TargetQoS =:= -1 -> QoS; true -> TargetQoS end, - from = Id, - flags = Flags, - headers = #{republish_by => Id}, - topic = emqx_rule_utils:proc_tmpl(TopicTks, Selected), - payload = emqx_rule_utils:proc_tmpl(PayloadTks, Selected), - timestamp = Timestamp - }); - %% in case this is not a "message.publish" request - (Selected, _Envs) -> - ?LOG(debug, "[republish] republish to: ~p, Payload: ~p", - [TargetTopic, Selected]), - increase_and_publish( - #message{ - id = emqx_guid:gen(), - qos = if TargetQoS =:= -1 -> 0; true -> TargetQoS end, - from = Id, - flags = #{dup => false, retain => false}, - headers = #{republish_by => Id}, - topic = emqx_rule_utils:proc_tmpl(TopicTks, Selected), - payload = emqx_rule_utils:proc_tmpl(PayloadTks, Selected), - timestamp = erlang:system_time(millisecond) - }) - end. + Params. -increase_and_publish(Msg) -> - emqx_metrics:inc_msg(Msg), - emqx_broker:safe_publish(Msg). +-spec on_action_republish(selected_data(), env_vars()) -> any(). +on_action_republish(_Selected, Envs = #{ + topic := Topic, + headers := #{republish_by := ActId}, + ?BINDING_KEYS := #{'Id' := ActId} + }) -> + ?LOG(error, "[republish] recursively republish detected, msg topic: ~p, target topic: ~p", + [Topic, ?bound_v('TargetTopic', Envs)]), + emqx_rule_metrics:inc_actions_error(?bound_v('Id', Envs)); -on_action_do_nothing(_, _) -> - fun(_, _) -> ok end. +on_action_republish(Selected, _Envs = #{ + qos := QoS, flags := Flags, timestamp := Timestamp, + ?BINDING_KEYS := #{ + 'Id' := ActId, + 'TargetTopic' := TargetTopic, + 'TargetQoS' := TargetQoS, + 'TopicTks' := TopicTks, + 'PayloadTks' := PayloadTks + }}) -> + ?LOG(debug, "[republish] republish to: ~p, Payload: ~p", + [TargetTopic, Selected]), + increase_and_publish(ActId, + #message{ + id = emqx_guid:gen(), + qos = if TargetQoS =:= -1 -> QoS; true -> TargetQoS end, + from = ActId, + flags = Flags, + headers = #{republish_by => ActId}, + topic = emqx_rule_utils:proc_tmpl(TopicTks, Selected), + payload = emqx_rule_utils:proc_tmpl(PayloadTks, Selected), + timestamp = Timestamp + }); + +%% in case this is not a "message.publish" request +on_action_republish(Selected, _Envs = #{ + ?BINDING_KEYS := #{ + 'Id' := ActId, + 'TargetTopic' := TargetTopic, + 'TargetQoS' := TargetQoS, + 'TopicTks' := TopicTks, + 'PayloadTks' := PayloadTks + }}) -> + ?LOG(debug, "[republish] republish to: ~p, Payload: ~p", + [TargetTopic, Selected]), + increase_and_publish(ActId, + #message{ + id = emqx_guid:gen(), + qos = if TargetQoS =:= -1 -> 0; true -> TargetQoS end, + from = ActId, + flags = #{dup => false, retain => false}, + headers = #{republish_by => ActId}, + topic = emqx_rule_utils:proc_tmpl(TopicTks, Selected), + payload = emqx_rule_utils:proc_tmpl(PayloadTks, Selected), + timestamp = erlang:system_time(millisecond) + }). + +increase_and_publish(ActId, Msg) -> + emqx_broker:safe_publish(Msg), + emqx_rule_metrics:inc_actions_success(ActId), + emqx_metrics:inc_msg(Msg). + +-spec on_action_create_do_nothing(action_instance_id(), Params :: map()) + -> NewParams :: map(). +on_action_create_do_nothing(ActId, Params) when is_binary(ActId) -> + Params. + +on_action_do_nothing(Selected, Envs) when is_map(Selected) -> + emqx_rule_metrics:inc_actions_success(?bound_v('ActId', Envs)). diff --git a/apps/emqx_rule_engine/src/emqx_rule_actions_trans.erl b/apps/emqx_rule_engine/src/emqx_rule_actions_trans.erl new file mode 100644 index 000000000..9ee21385b --- /dev/null +++ b/apps/emqx_rule_engine/src/emqx_rule_actions_trans.erl @@ -0,0 +1,70 @@ +-module(emqx_rule_actions_trans). + +-include_lib("syntax_tools/include/merl.hrl"). + +-export([parse_transform/2]). + +parse_transform(Forms, _Options) -> + trans(Forms, []). + +trans([], ResAST) -> + lists:reverse(ResAST); +trans([{eof, L} | AST], ResAST) -> + lists:reverse([{eof, L} | ResAST]) ++ AST; +trans([{function, LineNo, FuncName, Arity, Clauses} | AST], ResAST) -> + NewClauses = trans_func_clauses(atom_to_list(FuncName), Clauses), + trans(AST, [{function, LineNo, FuncName, Arity, NewClauses} | ResAST]); +trans([Form | AST], ResAST) -> + trans(AST, [Form | ResAST]). + +trans_func_clauses("on_action_create_" ++ _ = _FuncName , Clauses) -> + %io:format("~n[[transing function: ~p]]~n", [_FuncName]), + %io:format("~n-----old clauses:~n", []), merl:print(Clauses), + NewClauses = [ + begin + Bindings = lists:flatten(get_vars(Args) ++ get_vars(Body, lefth)), + Body2 = append_to_result(Bindings, Body), + {clause, LineNo, Args, Guards, Body2} + end || {clause, LineNo, Args, Guards, Body} <- Clauses], + %io:format("~n-----new clauses: ~n"), merl:print(NewClauses), + NewClauses; +trans_func_clauses(_FuncName, Clauses) -> + %io:format("~n[[discarding function: ~p]]~n", [_FuncName]), + Clauses. + +get_vars(Exprs) -> + get_vars(Exprs, all). +get_vars(Exprs, Type) -> + do_get_vars(Exprs, [], Type). + +do_get_vars([], Vars, _Type) -> Vars; +do_get_vars([Line | Expr], Vars, all) -> + do_get_vars(Expr, [syntax_vars(erl_syntax:form_list([Line])) | Vars], all); +do_get_vars([Line | Expr], Vars, lefth) -> + do_get_vars(Expr, + case (Line) of + ?Q("_@LeftV = _@@_") -> Vars ++ syntax_vars(LeftV); + _ -> Vars + end, lefth). + +syntax_vars(Line) -> + sets:to_list(erl_syntax_lib:variables(Line)). + +%% append bindings to the return value as the first tuple element. +%% e.g. if the original result is R, then the new result will be {[binding()], R}. +append_to_result(Bindings, Exprs) -> + erl_syntax:revert_forms(do_append_to_result(to_keyword(Bindings), Exprs, [])). + +do_append_to_result(KeyWordVars, [Line], Res) -> + case Line of + ?Q("_@LeftV = _@RightV") -> + lists:reverse([?Q("{[_@KeyWordVars], _@LeftV}"), Line | Res]); + _ -> + lists:reverse([?Q("{[_@KeyWordVars], _@Line}") | Res]) + end; +do_append_to_result(KeyWordVars, [Line | Exprs], Res) -> + do_append_to_result(KeyWordVars, Exprs, [Line | Res]). + +to_keyword(Vars) -> + [erl_syntax:tuple([erl_syntax:atom(Var), merl:var(Var)]) + || Var <- Vars]. diff --git a/apps/emqx_rule_engine/src/emqx_rule_engine.appup.src b/apps/emqx_rule_engine/src/emqx_rule_engine.appup.src index 36551e73b..90ff40f4f 100644 --- a/apps/emqx_rule_engine/src/emqx_rule_engine.appup.src +++ b/apps/emqx_rule_engine/src/emqx_rule_engine.appup.src @@ -1,44 +1,8 @@ -%% -*-: erlang -*- - -{"4.2.3", - [ - {"4.2.0", [ - {load_module, emqx_rule_events, brutal_purge, soft_purge, []}, - {load_module, emqx_rule_funcs, brutal_purge, soft_purge, []}, - {load_module, emqx_rule_maps, brutal_purge, soft_purge, []}, - {load_module, emqx_rule_engine, brutal_purge, soft_purge, []}, - {load_module, emqx_rule_actions, brutal_purge, soft_purge, []} - ]}, - {"4.2.1", [ - {load_module, emqx_rule_funcs, brutal_purge, soft_purge, []}, - {load_module, emqx_rule_maps, brutal_purge, soft_purge, []}, - {load_module, emqx_rule_engine, brutal_purge, soft_purge, []}, - {load_module, emqx_rule_actions, brutal_purge, soft_purge, []} - ]}, - {"4.2.2", [ - {load_module, emqx_rule_funcs, brutal_purge, soft_purge, []}, - {load_module, emqx_rule_engine, brutal_purge, soft_purge, []}, - {load_module, emqx_rule_actions, brutal_purge, soft_purge, []} - ]} - ], - [ - {"4.2.0", [ - {load_module, emqx_rule_events, brutal_purge, soft_purge, []}, - {load_module, emqx_rule_funcs, brutal_purge, soft_purge, []}, - {load_module, emqx_rule_maps, brutal_purge, soft_purge, []}, - {load_module, emqx_rule_engine, brutal_purge, soft_purge, []}, - {load_module, emqx_rule_actions, brutal_purge, soft_purge, []} - ]}, - {"4.2.1", [ - {load_module, emqx_rule_funcs, brutal_purge, soft_purge, []}, - {load_module, emqx_rule_maps, brutal_purge, soft_purge, []}, - {load_module, emqx_rule_engine, brutal_purge, soft_purge, []}, - {load_module, emqx_rule_actions, brutal_purge, soft_purge, []} - ]}, - {"4.2.2", [ - {load_module, emqx_rule_actions, brutal_purge, soft_purge, []}, - {load_module, emqx_rule_engine, brutal_purge, soft_purge, []}, - {load_module, emqx_rule_funcs, brutal_purge, soft_purge, []} - ]} - ] +{VSN, + [ + {<<".*">>, []} + ], + [ + {<<".*">>, []} + ] }. diff --git a/apps/emqx_rule_engine/src/emqx_rule_engine.erl b/apps/emqx_rule_engine/src/emqx_rule_engine.erl index 8384ef4db..1b74a00a0 100644 --- a/apps/emqx_rule_engine/src/emqx_rule_engine.erl +++ b/apps/emqx_rule_engine/src/emqx_rule_engine.erl @@ -20,10 +20,9 @@ -include_lib("emqx/include/logger.hrl"). -export([ load_providers/0 - , load_provider/1 , unload_providers/0 - , unload_provider/1 , refresh_resources/0 + , refresh_resource/1 , refresh_rule/1 , refresh_rules/0 , refresh_actions/1 @@ -174,6 +173,7 @@ create_rule(Params = #{rawsql := Sql, actions := Actions}) -> enabled = Enabled, description = maps:get(description, Params, "")}, ok = emqx_rule_registry:add_rule(Rule), + ok = emqx_rule_metrics:create_rule_metrics(RuleId), {ok, Rule}; Error -> error(Error) end. @@ -198,7 +198,7 @@ delete_rule(RuleId) -> ok = emqx_rule_registry:remove_rule(Rule) catch Error:Reason:ST -> - ?LOG(error, "clear_rule rule failed: ~p", [{Error, Reason, ST}]), + ?LOG(error, "clear_rule ~p failed: ~p", [RuleId, {Error, Reason, ST}]), refresh_actions(Actions, fun(_) -> true end) end; not_found -> @@ -300,6 +300,9 @@ refresh_resources() -> <- emqx_rule_registry:get_resources()], ok. +refresh_resource(Type) when is_atom(Type) -> + [refresh_resource(Resource) + || Resource <- emqx_rule_registry:get_resources_by_type(Type)]; refresh_resource(#resource{id = ResId, config = Config, type = Type}) -> {ok, #resource_type{on_create = {M, F}}} = emqx_rule_registry:find_resource_type(Type), cluster_call(init_resource, [M, F, ResId, Config]). @@ -317,7 +320,8 @@ refresh_rules() -> <- emqx_rule_registry:get_rules()], ok. -refresh_rule(#rule{actions = Actions}) -> +refresh_rule(#rule{id = RuleId, actions = Actions}) -> + ok = emqx_rule_metrics:create_rule_metrics(RuleId), refresh_actions(Actions, fun(_) -> true end). -spec(refresh_resource_status() -> ok). @@ -436,14 +440,20 @@ cluster_call(Func, Args) -> init_resource(Module, OnCreate, ResId, Config) -> Params = ?RAISE(Module:OnCreate(ResId, Config), {{init_resource_failure, node()}, {{Module, OnCreate}, {_REASON_,_ST_}}}), - emqx_rule_registry:add_resource_params(#resource_params{id = ResId, params = Params}). + emqx_rule_registry:add_resource_params(#resource_params{id = ResId, params = Params, status = #{is_alive => true}}). init_action(Module, OnCreate, ActionInstId, Params) -> + ok = emqx_rule_metrics:create_metrics(ActionInstId), case ?RAISE(Module:OnCreate(ActionInstId, Params), {{init_action_failure, node()}, {{Module,OnCreate},{_REASON_,_ST_}}}) of - {Apply, NewParams} -> + {Apply, NewParams} when is_function(Apply) -> %% BACKW: =< e4.2.2 ok = emqx_rule_registry:add_action_instance_params( #action_instance_params{id = ActionInstId, params = NewParams, apply = Apply}); - Apply -> + {Bindings, NewParams} when is_list(Bindings) -> + ok = emqx_rule_registry:add_action_instance_params( + #action_instance_params{ + id = ActionInstId, params = NewParams, + apply = #{mod => Module, bindings => maps:from_list(Bindings)}}); + Apply when is_function(Apply) -> %% BACKW: =< e4.2.2 ok = emqx_rule_registry:add_action_instance_params( #action_instance_params{id = ActionInstId, params = Params, apply = Apply}) end. @@ -462,7 +472,7 @@ clear_resource(Module, Destroy, ResId) -> clear_rule(#rule{id = RuleId, actions = Actions}) -> clear_actions(Actions), - emqx_rule_metrics:clear(RuleId), + emqx_rule_metrics:clear_rule_metrics(RuleId), ok. clear_actions(Actions) -> @@ -474,17 +484,21 @@ clear_actions(Actions) -> end, Actions). clear_action(_Module, undefined, ActionInstId) -> - emqx_rule_metrics:clear(ActionInstId), + emqx_rule_metrics:clear_metrics(ActionInstId), ok = emqx_rule_registry:remove_action_instance_params(ActionInstId); clear_action(Module, Destroy, ActionInstId) -> - emqx_rule_metrics:clear(ActionInstId), - case emqx_rule_registry:get_action_instance_params(ActionInstId) of - {ok, #action_instance_params{params = Params}} -> - ?RAISE(Module:Destroy(ActionInstId, Params),{{destroy_action_failure, node()}, - {{Module, Destroy}, {_REASON_,_ST_}}}), - ok = emqx_rule_registry:remove_action_instance_params(ActionInstId); - not_found -> - ok + case erlang:function_exported(Module, Destroy, 2) of + true -> + emqx_rule_metrics:clear_metrics(ActionInstId), + case emqx_rule_registry:get_action_instance_params(ActionInstId) of + {ok, #action_instance_params{params = Params}} -> + ?RAISE(Module:Destroy(ActionInstId, Params),{{destroy_action_failure, node()}, + {{Module, Destroy}, {_REASON_,_ST_}}}), + ok = emqx_rule_registry:remove_action_instance_params(ActionInstId); + not_found -> + ok + end; + false -> ok end. fetch_resource_status(Module, OnStatus, ResId) -> diff --git a/apps/emqx_rule_engine/src/emqx_rule_engine_api.erl b/apps/emqx_rule_engine/src/emqx_rule_engine_api.erl index f38ff4b10..a09d526ae 100644 --- a/apps/emqx_rule_engine/src/emqx_rule_engine_api.erl +++ b/apps/emqx_rule_engine/src/emqx_rule_engine_api.erl @@ -297,7 +297,17 @@ do_create_resource(Create, Params) -> end. list_resources(#{}, _Params) -> - return_all(emqx_rule_registry:get_resources()). + Data0 = lists:foldr(fun maybe_record_to_map/2, [], emqx_rule_registry:get_resources()), + Data = lists:map(fun(Res = #{id := Id}) -> + Status = lists:all(fun(Node) -> + case emqx_rpc:call(Node, emqx_rule_registry, find_resource_params, [Id]) of + {ok, #resource_params{status = #{is_alive := true}}} -> true; + _ -> false + end + end, ekka_mnesia:running_nodes()), + maps:put(status, Status, Res) + end, Data0), + return({ok, Data}). list_resources_by_type(#{type := Type}, _Params) -> return_all(emqx_rule_registry:get_resources_by_type(Type)). @@ -309,7 +319,7 @@ show_resource(#{id := Id}, _Params) -> [begin {ok, St} = rpc:call(Node, emqx_rule_engine, get_resource_status, [Id]), maps:put(node, Node, St) - end || Node <- [node()| nodes()]], + end || Node <- ekka_mnesia:running_nodes()], return({ok, maps:put(status, Status, record_to_map(R))}); not_found -> return({error, 404, <<"Not Found">>}) @@ -538,8 +548,8 @@ sort_by(Pos, TplList) -> get_rule_metrics(Id) -> [maps:put(node, Node, rpc:call(Node, emqx_rule_metrics, get_rule_metrics, [Id])) - || Node <- [node()| nodes()]]. + || Node <- ekka_mnesia:running_nodes()]. get_action_metrics(Id) -> [maps:put(node, Node, rpc:call(Node, emqx_rule_metrics, get_action_metrics, [Id])) - || Node <- [node()| nodes()]]. + || Node <- ekka_mnesia:running_nodes()]. diff --git a/apps/emqx_rule_engine/src/emqx_rule_engine_app.erl b/apps/emqx_rule_engine/src/emqx_rule_engine_app.erl index 2d1aa54f5..b244f8323 100644 --- a/apps/emqx_rule_engine/src/emqx_rule_engine_app.erl +++ b/apps/emqx_rule_engine/src/emqx_rule_engine_app.erl @@ -28,6 +28,7 @@ start(_Type, _Args) -> {ok, Sup} = emqx_rule_engine_sup:start_link(), + _ = emqx_rule_engine_sup:start_locker(), ok = emqx_rule_engine:load_providers(), ok = emqx_rule_engine:refresh_resources(), ok = emqx_rule_engine:refresh_rules(), diff --git a/apps/emqx_rule_engine/src/emqx_rule_engine_sup.erl b/apps/emqx_rule_engine/src/emqx_rule_engine_sup.erl index e550e4f6c..43de01593 100644 --- a/apps/emqx_rule_engine/src/emqx_rule_engine_sup.erl +++ b/apps/emqx_rule_engine/src/emqx_rule_engine_sup.erl @@ -22,6 +22,8 @@ -export([start_link/0]). +-export([start_locker/0]). + -export([init/1]). start_link() -> @@ -45,3 +47,11 @@ init([]) -> modules => [emqx_rule_metrics]}, {ok, {{one_for_all, 10, 100}, [Registry, Metrics]}}. +start_locker() -> + Locker = #{id => emqx_rule_locker, + start => {emqx_rule_locker, start_link, []}, + restart => permanent, + shutdown => 5000, + type => worker, + modules => [emqx_rule_locker]}, + supervisor:start_child(?MODULE, Locker). diff --git a/apps/emqx_rule_engine/src/emqx_rule_funcs.erl b/apps/emqx_rule_engine/src/emqx_rule_funcs.erl index 6795f1522..ed2e53efa 100644 --- a/apps/emqx_rule_engine/src/emqx_rule_funcs.erl +++ b/apps/emqx_rule_engine/src/emqx_rule_funcs.erl @@ -233,7 +233,7 @@ payload() -> payload(Path) -> fun(#{payload := Payload}) when erlang:is_map(Payload) -> - map_get(Path, Payload); + emqx_rule_maps:nested_get(map_path(Path), Payload); (_) -> undefined end. @@ -607,10 +607,52 @@ map_get(Key, Map) -> map_get(Key, Map, undefined). map_get(Key, Map, Default) -> - emqx_rule_maps:nested_get(map_path(Key), Map, Default). + case maps:find(Key, Map) of + {ok, Val} -> Val; + error when is_atom(Key) -> + %% the map may have an equivalent binary-form key + BinKey = emqx_rule_utils:bin(Key), + case maps:find(BinKey, Map) of + {ok, Val} -> Val; + error -> Default + end; + error when is_binary(Key) -> + try %% the map may have an equivalent atom-form key + AtomKey = list_to_existing_atom(binary_to_list(Key)), + case maps:find(AtomKey, Map) of + {ok, Val} -> Val; + error -> Default + end + catch error:badarg -> + Default + end; + error -> + Default + end. map_put(Key, Val, Map) -> - emqx_rule_maps:nested_put(map_path(Key), Val, Map). + case maps:find(Key, Map) of + {ok, _} -> maps:put(Key, Val, Map); + error when is_atom(Key) -> + %% the map may have an equivalent binary-form key + BinKey = emqx_rule_utils:bin(Key), + case maps:find(BinKey, Map) of + {ok, _} -> maps:put(BinKey, Val, Map); + error -> maps:put(Key, Val, Map) + end; + error when is_binary(Key) -> + try %% the map may have an equivalent atom-form key + AtomKey = list_to_existing_atom(binary_to_list(Key)), + case maps:find(AtomKey, Map) of + {ok, _} -> maps:put(AtomKey, Val, Map); + error -> maps:put(Key, Val, Map) + end + catch error:badarg -> + maps:put(Key, Val, Map) + end; + error -> + maps:put(Key, Val, Map) + end. mget(Key, Map) -> mget(Key, Map, undefined). diff --git a/apps/emqx_rule_engine/src/emqx_rule_locker.erl b/apps/emqx_rule_engine/src/emqx_rule_locker.erl new file mode 100644 index 000000000..40451a0d8 --- /dev/null +++ b/apps/emqx_rule_engine/src/emqx_rule_locker.erl @@ -0,0 +1,34 @@ +%%-------------------------------------------------------------------- +%% Copyright (c) 2020 EMQ Technologies Co., Ltd. All Rights Reserved. +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%%-------------------------------------------------------------------- + +-module(emqx_rule_locker). + +-export([start_link/0]). + +-export([ lock/1 + , unlock/1 + ]). + +start_link() -> + ekka_locker:start_link(?MODULE). + +-spec(lock(binary()) -> ekka_locker:lock_result()). +lock(Id) -> + ekka_locker:acquire(?MODULE, Id, local). + +-spec(unlock(binary()) -> {boolean(), [node()]}). +unlock(Id) -> + ekka_locker:release(?MODULE, Id, local). diff --git a/apps/emqx_rule_engine/src/emqx_rule_maps.erl b/apps/emqx_rule_engine/src/emqx_rule_maps.erl index 27c08dd12..7ccd16c0c 100644 --- a/apps/emqx_rule_engine/src/emqx_rule_maps.erl +++ b/apps/emqx_rule_engine/src/emqx_rule_maps.erl @@ -28,19 +28,17 @@ nested_get(Key, Data) -> nested_get(Key, Data, undefined). -nested_get({var, Key}, Data, Default) when is_map(Data) orelse is_list(Data) -> +nested_get({var, Key}, Data, Default) -> general_map_get({key, Key}, Data, Data, Default); nested_get({path, Path}, Data, Default) when is_map(Data) orelse is_list(Data), is_list(Path) -> do_nested_get(Path, Data, Data, Default); -nested_get(Key, JsonStr, Default) when is_binary(JsonStr) -> - try emqx_json:decode(JsonStr, [return_maps]) of +nested_get(Key, Data, Default) when not is_map(Data) -> + try emqx_json:decode(Data, [return_maps]) of Json -> nested_get(Key, Json, Default) catch _:_ -> Default - end; -nested_get(_Key, _InvalidData, Default) -> - Default. + end. do_nested_get([Key|More], Data, OrgData, Default) -> case general_map_get(Key, Data, OrgData, undefined) of @@ -83,12 +81,6 @@ general_map_put(Key, Val, Map, OrgData) -> (_) -> do_put(Key, Val, Map, OrgData) end). -general_find(Key, JsonStr, _OrgData, Handler) when is_binary(JsonStr) -> - try emqx_json:decode(JsonStr, [return_maps]) of - Json -> general_find(Key, Json, _OrgData, Handler) - catch - _:_ -> Handler(not_found) - end; general_find({key, Key}, Map, _OrgData, Handler) when is_map(Map) -> case maps:find(Key, Map) of {ok, Val} -> Handler({found, {{key, Key}, Val}}); diff --git a/apps/emqx_rule_engine/src/emqx_rule_metrics.erl b/apps/emqx_rule_engine/src/emqx_rule_metrics.erl index 3c1ec9c0f..c24954f94 100644 --- a/apps/emqx_rule_engine/src/emqx_rule_metrics.erl +++ b/apps/emqx_rule_engine/src/emqx_rule_metrics.erl @@ -25,14 +25,38 @@ , stop/0 ]). +-export([ get_rules_matched/1 + , get_actions_taken/1 + , get_actions_success/1 + , get_actions_error/1 + , get_actions_exception/1 + , get_actions_retry/1 + ]). + +-export([ inc_rules_matched/1 + , inc_rules_matched/2 + , inc_actions_taken/1 + , inc_actions_taken/2 + , inc_actions_success/1 + , inc_actions_success/2 + , inc_actions_error/1 + , inc_actions_error/2 + , inc_actions_exception/1 + , inc_actions_exception/2 + , inc_actions_retry/1 + , inc_actions_retry/2 + ]). + -export([ inc/2 , inc/3 , get/2 , get_overall/1 , get_rule_speed/1 , get_overall_rule_speed/0 - , create/1 - , clear/1 + , create_rule_metrics/1 + , create_metrics/1 + , clear_rule_metrics/1 + , clear_metrics/1 , overall_metrics/0 ]). @@ -45,6 +69,7 @@ , handle_call/3 , handle_info/2 , handle_cast/2 + , code_change/3 , terminate/2 ]). @@ -81,16 +106,20 @@ %%------------------------------------------------------------------------------ %% APIs %%------------------------------------------------------------------------------ --spec(create(rule_id()) -> Ref :: reference()). -create(<<"rule:", _/binary>> = Id) -> - gen_server:call(?MODULE, {create_rule_metrics, Id}); -create(Id) -> +-spec(create_rule_metrics(rule_id()) -> Ref :: reference()). +create_rule_metrics(Id) -> + gen_server:call(?MODULE, {create_rule_metrics, Id}). + +-spec(create_metrics(rule_id()) -> Ref :: reference()). +create_metrics(Id) -> gen_server:call(?MODULE, {create_metrics, Id}). --spec(clear(rule_id()) -> ok). -clear(<<"rule:", _/binary>> = Id) -> - gen_server:call(?MODULE, {delete_rule_metrics, Id}); -clear(Id) -> +-spec(clear_rule_metrics(rule_id()) -> ok). +clear_rule_metrics(Id) -> + gen_server:call(?MODULE, {delete_rule_metrics, Id}). + +-spec(clear_metrics(rule_id()) -> ok). +clear_metrics(Id) -> gen_server:call(?MODULE, {delete_metrics, Id}). -spec(get(rule_id(), atom()) -> number()). @@ -115,7 +144,7 @@ get_overall_rule_speed() -> -spec(get_rule_metrics(rule_id()) -> map()). get_rule_metrics(Id) -> #{max := Max, current := Current, last5m := Last5M} = get_rule_speed(Id), - #{matched => get(Id, 'rules.matched'), + #{matched => get_rules_matched(Id), speed => Current, speed_max => Max, speed_last5m => Last5M @@ -123,26 +152,70 @@ get_rule_metrics(Id) -> -spec(get_action_metrics(action_instance_id()) -> map()). get_action_metrics(Id) -> - #{success => get(Id, 'actions.success'), - failed => get(Id, 'actions.failure') + #{success => get_actions_success(Id), + failed => get_actions_error(Id) + get_actions_exception(Id), + taken => get_actions_taken(Id) }. -spec(inc(rule_id(), atom()) -> ok). inc(Id, Metric) -> inc(Id, Metric, 1). inc(Id, Metric, Val) -> - case couters_ref(Id) of - not_found -> - counters:add(create(Id), metrics_idx(Metric), Val); - Ref -> - counters:add(Ref, metrics_idx(Metric), Val) - end, + counters:add(couters_ref(Id), metrics_idx(Metric), Val), inc_overall(Metric, Val). -spec(inc_overall(rule_id(), atom()) -> ok). inc_overall(Metric, Val) -> emqx_metrics:inc(Metric, Val). +inc_rules_matched(Id) -> + inc_rules_matched(Id, 1). +inc_rules_matched(Id, Val) -> + inc(Id, 'rules.matched', Val). + +inc_actions_taken(Id) -> + inc_actions_taken(Id, 1). +inc_actions_taken(Id, Val) -> + inc(Id, 'actions.taken', Val). + +inc_actions_success(Id) -> + inc_actions_success(Id, 1). +inc_actions_success(Id, Val) -> + inc(Id, 'actions.success', Val). + +inc_actions_error(Id) -> + inc_actions_error(Id, 1). +inc_actions_error(Id, Val) -> + inc(Id, 'actions.error', Val). + +inc_actions_exception(Id) -> + inc_actions_exception(Id, 1). +inc_actions_exception(Id, Val) -> + inc(Id, 'actions.exception', Val). + +inc_actions_retry(Id) -> + inc_actions_retry(Id, 1). +inc_actions_retry(Id, Val) -> + inc(Id, 'actions.retry', Val). + +get_rules_matched(Id) -> + get(Id, 'rules.matched'). + +get_actions_taken(Id) -> + get(Id, 'actions.taken'). + +get_actions_success(Id) -> + get(Id, 'actions.success'). + +get_actions_error(Id) -> + get(Id, 'actions.error'). + +get_actions_exception(Id) -> + get(Id, 'actions.exception'). + +get_actions_retry(Id) -> + get(Id, 'actions.retry'). + start_link() -> gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). @@ -205,7 +278,7 @@ handle_info(ticking, State = #state{rule_speeds = RuleSpeeds0, overall_rule_speed = OverallRuleSpeed0}) -> RuleSpeeds = maps:map( fun(Id, RuleSpeed) -> - calculate_speed(get(Id, 'rules.matched'), RuleSpeed) + calculate_speed(get_rules_matched(Id), RuleSpeed) end, RuleSpeeds0), OverallRuleSpeed = calculate_speed(get_overall('rules.matched'), OverallRuleSpeed0), async_refresh_resource_status(), @@ -216,6 +289,50 @@ handle_info(ticking, State = #state{rule_speeds = RuleSpeeds0, handle_info(_Info, State) -> {noreply, State}. +code_change({down, Vsn}, State = #state{metric_ids = MIDs}, _Extra) + when Vsn =:= "4.2.0"; + Vsn =:= "4.2.1" -> + emqx_metrics:ensure('actions.failure'), + emqx_metrics:set('actions.failure', + emqx_metrics:val('actions.error') + + emqx_metrics:val('actions.exception')), + [begin + Matched = get_rules_matched(Id), + Succ = get_actions_success(Id), + Error = get_actions_error(Id), + Except = get_actions_exception(Id), + ok = delete_counters(Id), + ok = create_counters(Id), + inc_rules_matched(Id, Matched), + inc_actions_success(Id, Succ), + inc_actions_error(Id, Error + Except) + end || Id <- sets:to_list(MIDs)], + {ok, State}; + +code_change(Vsn, State = #state{metric_ids = MIDs}, _Extra) + when Vsn =:= "4.2.0"; + Vsn =:= "4.2.1" -> + [emqx_metrics:ensure(Name) + || Name <- + ['actions.error', 'actions.taken', + 'actions.exception', 'actions.retry' + ]], + emqx_metrics:set('actions.error', emqx_metrics:val('actions.failure')), + [begin + Matched = get_rules_matched(Id), + Succ = get_actions_success(Id), + Error = get_actions_error(Id), + ok = delete_counters(Id), + ok = create_counters(Id), + inc_rules_matched(Id, Matched), + inc_actions_success(Id, Succ), + inc_actions_error(Id, Error) + end || Id <- sets:to_list(MIDs)], + {ok, State}; + +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + terminate(_Reason, #state{metric_ids = MIDs}) -> [delete_counters(Id) || Id <- sets:to_list(MIDs)], persistent_term:erase(?MODULE). @@ -231,9 +348,12 @@ async_refresh_resource_status() -> spawn(emqx_rule_engine, refresh_resource_status, []). create_counters(Id) -> - CRef = counters:new(max_counters_size(), [write_concurrency]), - ok = persistent_term:put(?CRefID(Id), CRef), - CRef. + case couters_ref(Id) of + not_found -> + ok = persistent_term:put(?CRefID(Id), + counters:new(max_counters_size(), [write_concurrency])); + _Ref -> ok + end. delete_counters(Id) -> persistent_term:erase(?CRefID(Id)), @@ -287,12 +407,21 @@ precision(Float, N) -> %% Metrics Definitions %%------------------------------------------------------------------------------ -max_counters_size() -> 4. +max_counters_size() -> 7. -metrics_idx('rules.matched') -> 1; -metrics_idx('actions.success') -> 2; -metrics_idx('actions.failure') -> 3; -metrics_idx(_) -> 4. +metrics_idx('rules.matched') -> 1; +metrics_idx('actions.success') -> 2; +metrics_idx('actions.error') -> 3; +metrics_idx('actions.taken') -> 4; +metrics_idx('actions.exception') -> 5; +metrics_idx('actions.retry') -> 6; +metrics_idx(_) -> 7. overall_metrics() -> - ['rules.matched', 'actions.success', 'actions.failure']. + [ 'rules.matched' + , 'actions.success' + , 'actions.error' + , 'actions.taken' + , 'actions.exception' + , 'actions.retry' + ]. diff --git a/apps/emqx_rule_engine/src/emqx_rule_runtime.erl b/apps/emqx_rule_engine/src/emqx_rule_runtime.erl index ff094d74c..7b40a12fc 100644 --- a/apps/emqx_rule_engine/src/emqx_rule_runtime.erl +++ b/apps/emqx_rule_engine/src/emqx_rule_runtime.erl @@ -38,7 +38,7 @@ -define(ephemeral_alias(TYPE, NAME), iolist_to_binary(io_lib:format("_v_~s_~p_~p", [TYPE, NAME, erlang:system_time()]))). --define(ActionMaxRetry, 1). +-define(ActionMaxRetry, 3). %%------------------------------------------------------------------------------ %% Apply rules @@ -227,19 +227,21 @@ take_actions(Actions, Selected, Envs, OnFailed) -> [take_action(ActInst, Selected, Envs, OnFailed, ?ActionMaxRetry) || ActInst <- Actions]. -take_action(#action_instance{id = Id, fallbacks = Fallbacks} = ActInst, +take_action(#action_instance{id = Id, name = ActName, fallbacks = Fallbacks} = ActInst, Selected, Envs, OnFailed, RetryN) when RetryN >= 0 -> try {ok, #action_instance_params{apply = Apply}} = emqx_rule_registry:get_action_instance_params(Id), - Result = Apply(Selected, Envs), - emqx_rule_metrics:inc(Id, 'actions.success'), - Result + emqx_rule_metrics:inc_actions_taken(Id), + apply_action_func(Selected, Envs, Apply, ActName) catch - error:{badfun, Func}:_Stack -> - ?LOG(warning, "Action ~p maybe outdated, refresh it and try again." - "Func: ~p", [Id, Func]), - _ = emqx_rule_engine:refresh_actions([ActInst]), + error:{badfun, _Func}:_ST -> + %?LOG(warning, "Action ~p maybe outdated, refresh it and try again." + % "Func: ~p~nST:~0p", [Id, Func, ST]), + trans_action_on(Id, fun() -> + emqx_rule_engine:refresh_actions([ActInst]) + end, 5000), + emqx_rule_metrics:inc_actions_retry(Id), take_action(ActInst, Selected, Envs, OnFailed, RetryN-1); Error:Reason:Stack -> handle_action_failure(OnFailed, Id, Fallbacks, Selected, Envs, {Error, Reason, Stack}) @@ -248,13 +250,46 @@ take_action(#action_instance{id = Id, fallbacks = Fallbacks} = ActInst, take_action(#action_instance{id = Id, fallbacks = Fallbacks}, Selected, Envs, OnFailed, _RetryN) -> handle_action_failure(OnFailed, Id, Fallbacks, Selected, Envs, {max_try_reached, ?ActionMaxRetry}). +apply_action_func(Data, Envs, #{mod := Mod, bindings := Bindings}, Name) -> + %% TODO: Build the Func Name when creating the action + Func = cbk_on_action_triggered(Name), + Mod:Func(Data, Envs#{'__bindings__' => Bindings}); +apply_action_func(Data, Envs, Func, _Name) when is_function(Func) -> + erlang:apply(Func, [Data, Envs]). + +cbk_on_action_triggered(Name) -> + list_to_atom("on_action_" ++ atom_to_list(Name)). + +trans_action_on(Id, Callback, Timeout) -> + case emqx_rule_locker:lock(Id) of + true -> try Callback() after emqx_rule_locker:unlock(Id) end; + _ -> + wait_action_on(Id, Timeout div 10) + end. + +wait_action_on(_, 0) -> + {error, timeout}; +wait_action_on(Id, RetryN) -> + timer:sleep(10), + case emqx_rule_registry:get_action_instance_params(Id) of + not_found -> + {error, not_found}; + {ok, #action_instance_params{apply = Apply}} -> + case catch apply_action_func(baddata, #{}, Apply, tryit) of + {'EXIT', {{badfun, _}, _}} -> + wait_action_on(Id, RetryN-1); + _ -> + ok + end + end. + handle_action_failure(continue, Id, Fallbacks, Selected, Envs, Reason) -> - emqx_rule_metrics:inc(Id, 'actions.failure'), + emqx_rule_metrics:inc_actions_exception(Id), ?LOG(error, "Take action ~p failed, continue next action, reason: ~0p", [Id, Reason]), take_actions(Fallbacks, Selected, Envs, continue), failed; handle_action_failure(stop, Id, Fallbacks, Selected, Envs, Reason) -> - emqx_rule_metrics:inc(Id, 'actions.failure'), + emqx_rule_metrics:inc_actions_exception(Id), ?LOG(error, "Take action ~p failed, skip all actions, reason: ~0p", [Id, Reason]), take_actions(Fallbacks, Selected, Envs, continue), error({take_action_failed, {Id, Reason}}). diff --git a/apps/emqx_rule_engine/src/emqx_rule_sqlparser.erl b/apps/emqx_rule_engine/src/emqx_rule_sqlparser.erl index 49c2264a1..03a2e816e 100644 --- a/apps/emqx_rule_engine/src/emqx_rule_sqlparser.erl +++ b/apps/emqx_rule_engine/src/emqx_rule_sqlparser.erl @@ -103,12 +103,3 @@ select_from(#select{from = From}) -> select_where(#select{where = Where}) -> Where. -fixed_columns() -> - ?COLUMNS('message.publish') ++ - ?COLUMNS('message.acked') ++ - ?COLUMNS('message.dropped') ++ - ?COLUMNS('client.connected') ++ - ?COLUMNS('client.disconnected') ++ - ?COLUMNS('session.subscribed') ++ - ?COLUMNS('session.unsubscribed') ++ - [<<"item">>]. diff --git a/apps/emqx_rule_engine/src/emqx_rule_sqltester.erl b/apps/emqx_rule_engine/src/emqx_rule_sqltester.erl index b77093da6..aeb1bf588 100644 --- a/apps/emqx_rule_engine/src/emqx_rule_sqltester.erl +++ b/apps/emqx_rule_engine/src/emqx_rule_sqltester.erl @@ -44,6 +44,8 @@ test(#{<<"rawsql">> := Sql, <<"ctx">> := Context}) -> test_rule(Sql, Select, Context, EventTopics) -> RuleId = iolist_to_binary(["test_rule", emqx_rule_id:gen()]), ActInstId = iolist_to_binary(["test_action", emqx_rule_id:gen()]), + ok = emqx_rule_metrics:create_rule_metrics(RuleId), + ok = emqx_rule_metrics:create_metrics(ActInstId), Rule = #rule{ id = RuleId, rawsql = Sql, @@ -63,7 +65,10 @@ test_rule(Sql, Select, Context, EventTopics) -> #action_instance_params{id = ActInstId, params = #{}, apply = sql_test_action()}), - emqx_rule_runtime:apply_rule(Rule, FullContext) + R = emqx_rule_runtime:apply_rule(Rule, FullContext), + emqx_rule_metrics:clear_rule_metrics(RuleId), + emqx_rule_metrics:clear_metrics(ActInstId), + R of {ok, Data} -> {ok, flatten(Data)}; {error, nomatch} -> {error, nomatch} diff --git a/apps/emqx_rule_engine/src/emqx_rule_utils.erl b/apps/emqx_rule_engine/src/emqx_rule_utils.erl index 68ae5b479..e4e2f5af5 100644 --- a/apps/emqx_rule_engine/src/emqx_rule_utils.erl +++ b/apps/emqx_rule_engine/src/emqx_rule_utils.erl @@ -19,8 +19,14 @@ %% preprocess and process tempalte string with place holders -export([ preproc_tmpl/1 , proc_tmpl/2 + , proc_tmpl/3 + , preproc_cmd/1 + , proc_cmd/2 + , proc_cmd/3 , preproc_sql/1 - , preproc_sql/2]). + , preproc_sql/2 + , proc_sql/2 + ]). %% type converting -export([ str/1 @@ -53,9 +59,13 @@ -define(EX_PLACE_HOLDER, "(\\$\\{[a-zA-Z0-9\\._]+\\})"). +-define(EX_WITHE_CHARS, "\\s"). %% Space and CRLF + -type(uri_string() :: iodata()). --type(tmpl_token() :: list({var, fun()} | {str, binary()})). +-type(tmpl_token() :: list({var, binary()} | {str, binary()})). + +-type(tmpl_cmd() :: list(tmpl_token())). -type(prepare_statement() :: binary()). @@ -70,9 +80,8 @@ preproc_tmpl(Str) -> preproc_tmpl([], Acc) -> lists:reverse(Acc); preproc_tmpl([[Str, Phld]| Tokens], Acc) -> - GetVarFun = fun(Data) -> get_phld_var(Phld, Data) end, preproc_tmpl(Tokens, - put_head(var, GetVarFun, + put_head(var, parse_nested(unwrap(Phld)), put_head(str, Str, Acc))); preproc_tmpl([[Str]| Tokens], Acc) -> preproc_tmpl(Tokens, put_head(str, Str, Acc)). @@ -81,13 +90,35 @@ put_head(_Type, <<>>, List) -> List; put_head(Type, Term, List) -> [{Type, Term} | List]. --spec(proc_tmpl(tmpl_token(), binary()) -> binary()). +-spec(proc_tmpl(tmpl_token(), map()) -> binary()). proc_tmpl(Tokens, Data) -> + proc_tmpl(Tokens, Data, #{return => full_binary}). + +proc_tmpl(Tokens, Data, Opts = #{return := full_binary}) -> + Trans = maps:get(var_trans, Opts, fun bin/1), list_to_binary( - lists:map( - fun ({str, Str}) -> Str; - ({var, GetVal}) -> bin(GetVal(Data)) - end, Tokens)). + proc_tmpl(Tokens, Data, #{return => rawlist, var_trans => Trans})); + +proc_tmpl(Tokens, Data, Opts = #{return := rawlist}) -> + Trans = maps:get(var_trans, Opts, undefined), + lists:map( + fun ({str, Str}) -> Str; + ({var, Phld}) when is_function(Trans) -> + Trans(get_phld_var(Phld, Data)); + ({var, Phld}) -> + get_phld_var(Phld, Data) + end, Tokens). + + +-spec(preproc_cmd(binary()) -> tmpl_cmd()). +preproc_cmd(Str) -> + SubStrList = re:split(Str, ?EX_WITHE_CHARS, [{return,binary},trim]), + [preproc_tmpl(SubStr) || SubStr <- SubStrList]. + +proc_cmd(Tokens, Data) -> + proc_cmd(Tokens, Data, #{return => full_binary}). +proc_cmd(Tokens, Data, Opts) -> + [proc_tmpl(Tks, Data, Opts) || Tks <- Tokens]. %% preprocess SQL with place holders -spec(preproc_sql(Sql::binary()) -> {prepare_statement(), prepare_params()}). @@ -98,21 +129,25 @@ preproc_sql(Sql) -> preproc_sql(Sql, ReplaceWith) -> case re:run(Sql, ?EX_PLACE_HOLDER, [{capture, all_but_first, binary}, global]) of {match, PlaceHolders} -> - {repalce_with(Sql, ReplaceWith), - fun(Data) -> - [sql_data(get_phld_var(Phld, Data)) - || Phld <- [hd(PH) || PH <- PlaceHolders]] - end}; + PhKs = [parse_nested(unwrap(Phld)) || [Phld | _] <- PlaceHolders], + {replace_with(Sql, ReplaceWith), [{var, Phld} || Phld <- PhKs]}; nomatch -> - {Sql, fun(_) -> [] end} + {Sql, []} end. -get_phld_var(Phld, Data) -> - emqx_rule_maps:nested_get(parse_nested(unwrap(Phld)), Data). +-spec(proc_sql(tmpl_token(), map()) -> list()). +proc_sql(Tokens, Data) -> + proc_tmpl(Tokens, Data, #{return => rawlist, var_trans => fun sql_data/1}). -repalce_with(Tmpl, '?') -> +%% backward compatibility for hot upgrading from =< e4.2.1 +get_phld_var(Fun, Data) when is_function(Fun) -> + Fun(Data); +get_phld_var(Phld, Data) -> + emqx_rule_maps:nested_get(Phld, Data). + +replace_with(Tmpl, '?') -> re:replace(Tmpl, ?EX_PLACE_HOLDER, "?", [{return, binary}, global]); -repalce_with(Tmpl, '$n') -> +replace_with(Tmpl, '$n') -> Parts = re:split(Tmpl, ?EX_PLACE_HOLDER, [{return, binary}, trim, group]), {Res, _} = lists:foldl( diff --git a/apps/emqx_rule_engine/src/emqx_rule_validator.erl b/apps/emqx_rule_engine/src/emqx_rule_validator.erl index 76c5ea19c..8e0ab82b8 100644 --- a/apps/emqx_rule_engine/src/emqx_rule_validator.erl +++ b/apps/emqx_rule_engine/src/emqx_rule_validator.erl @@ -25,7 +25,7 @@ -type(params_spec() :: #{atom() => term()}). -type(params() :: #{binary() => term()}). --define(DATA_TYPES, [string, number, float, boolean, object, array, file]). +-define(DATA_TYPES, [string, password, number, float, boolean, object, array, file, cfgselect]). %%------------------------------------------------------------------------------ %% APIs @@ -72,6 +72,8 @@ validate_type(Val, file, _Spec) -> ok = validate_file(Val); validate_type(Val, string, Spec) -> ok = validate_string(Val, reg_exp(maps:get(format, Spec, any))); +validate_type(Val, password, Spec) -> + ok = validate_string(Val, reg_exp(maps:get(format, Spec, any))); validate_type(Val, number, Spec) -> ok = validate_number(Val, maps:get(range, Spec, any)); validate_type(Val, boolean, _Spec) -> @@ -79,6 +81,8 @@ validate_type(Val, boolean, _Spec) -> validate_type(Val, array, Spec) -> [do_validate_param(V, maps:get(items, Spec)) || V <- Val], ok; +validate_type(_Val, cfgselect, _Spec) -> + ok; validate_type(Val, object, Spec) -> ok = validate_object(Val, maps:get(schema, Spec, any)). @@ -112,6 +116,8 @@ validate_boolean(true) -> ok; validate_boolean(false) -> ok; validate_boolean(Val) -> error({invalid_data_type, {boolean, Val}}). +validate_file(Val) when is_map(Val) -> ok; +validate_file(Val) when is_list(Val) -> ok; validate_file(Val) when is_binary(Val) -> ok; validate_file(Val) -> error({invalid_data_type, {file, Val}}). diff --git a/apps/emqx_rule_engine/test/emqx_rule_engine_SUITE.erl b/apps/emqx_rule_engine/test/emqx_rule_engine_SUITE.erl index 348ef046f..e097e578b 100644 --- a/apps/emqx_rule_engine/test/emqx_rule_engine_SUITE.erl +++ b/apps/emqx_rule_engine/test/emqx_rule_engine_SUITE.erl @@ -349,7 +349,7 @@ t_republish_action(_Config) -> end, emqtt:stop(Client), emqx_rule_registry:remove_rule(Id), - ?assertEqual(2, emqx_metrics:val('messages.qos0.received') - Qos0Received ), + ?assertEqual(2, emqx_metrics:val('messages.qos0.received') - Qos0Received), ?assertEqual(2, emqx_metrics:val('messages.received') - Received), ok. @@ -358,13 +358,14 @@ t_republish_action(_Config) -> %%------------------------------------------------------------------------------ t_crud_rule_api(_Config) -> - {ok, #{code := 0, data := Rule = #{id := RuleID}}} = + {ok, #{code := 0, data := Rule}} = emqx_rule_engine_api:create_rule(#{}, [{<<"name">>, <<"debug-rule">>}, {<<"rawsql">>, <<"select * from \"t/a\"">>}, {<<"actions">>, [[{<<"name">>,<<"inspect">>}, {<<"params">>,[{<<"arg1">>,1}]}]]}, {<<"description">>, <<"debug rule">>}]), + RuleID = maps:get(id, Rule), %ct:pal("RCreated : ~p", [Rule]), {ok, #{code := 0, data := Rules}} = emqx_rule_engine_api:list_rules(#{},[]), @@ -378,10 +379,10 @@ t_crud_rule_api(_Config) -> {ok, #{code := 0, data := Rule2}} = emqx_rule_engine_api:update_rule(#{id => RuleID}, [{<<"rawsql">>, <<"select * from \"t/b\"">>}]), - {ok, #{code := 0, data := Rule3 = #{rawsql := SQL}}} = emqx_rule_engine_api:show_rule(#{id => RuleID}, []), + {ok, #{code := 0, data := Rule3}} = emqx_rule_engine_api:show_rule(#{id => RuleID}, []), %ct:pal("RShow : ~p", [Rule1]), ?assertEqual(Rule3, Rule2), - ?assertEqual(<<"select * from \"t/b\"">>, SQL), + ?assertEqual(<<"select * from \"t/b\"">>, maps:get(rawsql, Rule3)), {ok, #{code := 0, data := Rule4}} = emqx_rule_engine_api:update_rule(#{id => RuleID}, [{<<"actions">>, @@ -396,17 +397,16 @@ t_crud_rule_api(_Config) -> ]] }]), - {ok, #{code := 0, data := Rule5 = #{actions := Actions}}} - = emqx_rule_engine_api:show_rule(#{id => RuleID}, []), + {ok, #{code := 0, data := Rule5}} = emqx_rule_engine_api:show_rule(#{id => RuleID}, []), %ct:pal("RShow : ~p", [Rule1]), ?assertEqual(Rule5, Rule4), - ?assertMatch([#{name := republish }], Actions), + ?assertMatch([#{name := republish }], maps:get(actions, Rule5)), ?assertMatch({ok, #{code := 0}}, emqx_rule_engine_api:delete_rule(#{id => RuleID}, [])), NotFound = emqx_rule_engine_api:show_rule(#{id => RuleID}, []), %ct:pal("Show After Deleted: ~p", [NotFound]), - ?assertMatch({ok, #{code := 404, message := _}}, NotFound), + ?assertMatch({ok, #{code := 404, message := _Message}}, NotFound), ok. t_list_actions_api(_Config) -> @@ -416,27 +416,27 @@ t_list_actions_api(_Config) -> ok. t_show_action_api(_Config) -> - ?assertMatch({ok, #{code := 0, data := #{name := 'inspect'}}}, - emqx_rule_engine_api:show_action(#{name => 'inspect'},[])), + {ok, #{code := 0, data := Actions}} = emqx_rule_engine_api:show_action(#{name => 'inspect'},[]), + ?assertEqual('inspect', maps:get(name, Actions)), ok. t_crud_resources_api(_Config) -> - {ok, #{code := 0, data := #{id := ResId}}} = + {ok, #{code := 0, data := Resources1}} = emqx_rule_engine_api:create_resource(#{}, [{<<"name">>, <<"Simple Resource">>}, {<<"type">>, <<"built_in">>}, {<<"config">>, [{<<"a">>, 1}]}, {<<"description">>, <<"Simple Resource">>}]), + ResId = maps:get(id, Resources1), {ok, #{code := 0, data := Resources}} = emqx_rule_engine_api:list_resources(#{},[]), ?assert(length(Resources) > 0), - ?assertMatch({ok, #{code := 0, data := #{id := ResId}}}, - emqx_rule_engine_api:show_resource(#{id => ResId},[])), + {ok, #{code := 0, data := Resources2}} = emqx_rule_engine_api:show_resource(#{id => ResId},[]), + ?assertEqual(ResId, maps:get(id, Resources2)), ?assertMatch({ok, #{code := 0}}, emqx_rule_engine_api:delete_resource(#{id => ResId},#{})), - ?assertMatch({ok, #{code := 404}}, - emqx_rule_engine_api:show_resource(#{id => ResId},[])), + ?assertMatch({ok, #{code := 404}}, emqx_rule_engine_api:show_resource(#{id => ResId},[])), ok. t_list_resource_types_api(_Config) -> @@ -445,9 +445,9 @@ t_list_resource_types_api(_Config) -> ok. t_show_resource_type_api(_Config) -> - RShow = emqx_rule_engine_api:show_resource_type(#{name => 'built_in'},[]), + {ok, #{code := 0, data := RShow}} = emqx_rule_engine_api:show_resource_type(#{name => 'built_in'},[]), %ct:pal("RShow : ~p", [RShow]), - ?assertMatch({ok, #{code := 0, data := #{name := built_in}} }, RShow), + ?assertEqual(built_in, maps:get(name, RShow)), ok. %%------------------------------------------------------------------------------ @@ -565,6 +565,7 @@ t_add_get_remove_rule(_Config) -> ok. t_add_get_remove_rules(_Config) -> + emqx_rule_registry:remove_rules(emqx_rule_registry:get_rules()), ok = emqx_rule_registry:add_rules( [make_simple_rule(<<"rule-debug-1">>), make_simple_rule(<<"rule-debug-2">>)]), @@ -802,6 +803,29 @@ message_acked(_Client) -> verify_event('message.acked'), ok. +t_mfa_action(_Config) -> + ok = emqx_rule_registry:add_action( + #action{name = 'mfa-action', app = ?APP, + module = ?MODULE, on_create = mfa_action, + types=[], params_spec = #{}, + title = #{en => <<"MFA callback action">>}, + description = #{en => <<"MFA callback action">>}}), + SQL = "SELECT * FROM \"t1\"", + {ok, #rule{id = Id}} = emqx_rule_engine:create_rule( + #{id => <<"rule:t_mfa_action">>, + rawsql => SQL, + actions => [#{id => <<"action:mfa-test">>, name => 'mfa-action', args => #{}}], + description => <<"Debug rule">>}), + {ok, Client} = emqtt:start_link([{username, <<"emqx">>}]), + {ok, _} = emqtt:connect(Client), + emqtt:publish(Client, <<"t1">>, <<"{\"id\": 1, \"name\": \"ha\"}">>, 0), + emqtt:stop(Client), + ct:sleep(500), + ?assertEqual(1, persistent_term:get(<<"action:mfa-test">>, 0)), + emqx_rule_registry:remove_rule(Id), + emqx_rule_registry:remove_action('mfa-action'), + ok. + t_match_atom_and_binary(_Config) -> ok = emqx_rule_engine:load_providers(), TopicRule = create_simple_repub_rule( @@ -1974,6 +1998,14 @@ hook_metrics_action(_Id, _Params) -> ct:pal("applying hook_metrics_action: ~p", [Data]), ets:insert(events_record_tab, {EventName, Data}) end. + +mfa_action(Id, _Params) -> + persistent_term:put(Id, 0), + {?MODULE, mfa_action_do, [Id]}. + +mfa_action_do(_Data, _Envs, K) -> + persistent_term:put(K, 1). + crash_action(_Id, _Params) -> fun(Data, _Envs) -> ct:pal("applying crash action, Data: ~p", [Data]), @@ -2252,7 +2284,7 @@ start_apps() -> [start_apps(App, SchemaFile, ConfigFile) || {App, SchemaFile, ConfigFile} <- [{emqx, deps_path(emqx, "priv/emqx.schema"), - deps_path(emqx, "etc/emqx.conf")}, + deps_path(emqx, "etc/gen.emqx.conf")}, {emqx_rule_engine, local_path("priv/emqx_rule_engine.schema"), local_path("etc/emqx_rule_engine.conf")}]]. diff --git a/apps/emqx_rule_engine/test/emqx_rule_funcs_SUITE.erl b/apps/emqx_rule_engine/test/emqx_rule_funcs_SUITE.erl index 582eb5832..98e62f415 100644 --- a/apps/emqx_rule_engine/test/emqx_rule_funcs_SUITE.erl +++ b/apps/emqx_rule_engine/test/emqx_rule_funcs_SUITE.erl @@ -489,15 +489,11 @@ t_contains(_) -> t_map_get(_) -> ?assertEqual(1, apply_func(map_get, [<<"a">>, #{a => 1}])), - ?assertEqual(undefined, apply_func(map_get, [<<"a">>, #{}])), - ?assertEqual(1, apply_func(map_get, [<<"a.b">>, #{a => #{b => 1}}])), - ?assertEqual(undefined, apply_func(map_get, [<<"a.c">>, #{a => #{b => 1}}])). + ?assertEqual(undefined, apply_func(map_get, [<<"a">>, #{}])). t_map_put(_) -> ?assertEqual(#{<<"a">> => 1}, apply_func(map_put, [<<"a">>, 1, #{}])), - ?assertEqual(#{a => 2}, apply_func(map_put, [<<"a">>, 2, #{a => 1}])), - ?assertEqual(#{<<"a">> => #{<<"b">> => 1}}, apply_func(map_put, [<<"a.b">>, 1, #{}])), - ?assertEqual(#{a => #{b => 1, <<"c">> => 1}}, apply_func(map_put, [<<"a.c">>, 1, #{a => #{b => 1}}])). + ?assertEqual(#{a => 2}, apply_func(map_put, [<<"a">>, 2, #{a => 1}])). t_mget(_) -> ?assertEqual(1, apply_func(map_get, [<<"a">>, #{a => 1}])), diff --git a/apps/emqx_rule_engine/test/emqx_rule_maps_SUITE.erl b/apps/emqx_rule_engine/test/emqx_rule_maps_SUITE.erl index 812de1ef0..2a7ccb9f6 100644 --- a/apps/emqx_rule_engine/test/emqx_rule_maps_SUITE.erl +++ b/apps/emqx_rule_engine/test/emqx_rule_maps_SUITE.erl @@ -43,9 +43,6 @@ t_nested_put_map(_) -> ?assertEqual(#{a => 1}, nested_put(?path([a]), 1, #{})), ?assertEqual(#{a => a}, nested_put(?path([a]), a, #{})), ?assertEqual(#{a => 1}, nested_put(?path([a]), 1, not_map)), - ?assertMatch(#{payload := #{<<"msg">> := <<"v">>}}, - nested_put(?path([<<"payload">>, <<"msg">>]), <<"v">>, - #{payload => <<"{\n \"msg\": \"hello\"\n}">>})), ?assertEqual(#{a => #{b => b}}, nested_put(?path([a,b]), b, #{})), ?assertEqual(#{a => #{b => #{c => c}}}, nested_put(?path([a,b,c]), c, #{})), ?assertEqual(#{<<"k">> => v1}, nested_put(?path([k]), v1, #{<<"k">> => v0})), @@ -94,9 +91,6 @@ t_nested_put_mix_map_index(_) -> t_nested_get_map(_) -> ?assertEqual(undefined, nested_get(?path([a]), not_map)), - ?assertEqual(<<"hello">>, nested_get(?path([msg]), <<"{\n \"msg\": \"hello\"\n}">>)), - ?assertEqual(<<"hello">>, nested_get(?path([<<"msg">>]), <<"{\n \"msg\": \"hello\"\n}">>)), - ?assertEqual(<<"hello">>, nested_get(?path([<<"payload">>, <<"msg">>]), #{payload => <<"{\n \"msg\": \"hello\"\n}">>})), ?assertEqual(#{a => 1}, nested_get(?path([]), #{a => 1})), ?assertEqual(#{b => c}, nested_get(?path([a]), #{a => #{b => c}})), ?assertEqual(undefined, nested_get(?path([a,b,c]), not_map)), diff --git a/apps/emqx_rule_engine/test/emqx_rule_metrics_SUITE.erl b/apps/emqx_rule_engine/test/emqx_rule_metrics_SUITE.erl index e95d297eb..c006f704c 100644 --- a/apps/emqx_rule_engine/test/emqx_rule_metrics_SUITE.erl +++ b/apps/emqx_rule_engine/test/emqx_rule_metrics_SUITE.erl @@ -34,6 +34,7 @@ groups() -> [ t_action , t_rule , t_clear + , t_no_creation ]}, {speed, [sequence], [ rule_speed @@ -59,43 +60,59 @@ init_per_testcase(_, Config) -> end_per_testcase(_, _Config) -> ok. +t_no_creation(_) -> + ?assertError(_, emqx_rule_metrics:inc_actions_taken(<<"action:0">>)). + t_action(_) -> - ?assertEqual(0, emqx_rule_metrics:get(<<"action:1">>, 'actions.success')), - ?assertEqual(0, emqx_rule_metrics:get(<<"action:1">>, 'actions.failure')), - ?assertEqual(0, emqx_rule_metrics:get(<<"action:2">>, 'actions.success')), - ok = emqx_rule_metrics:inc(<<"action:1">>, 'actions.success'), - ok = emqx_rule_metrics:inc(<<"action:1">>, 'actions.failure'), - ok = emqx_rule_metrics:inc(<<"action:2">>, 'actions.success'), - ok = emqx_rule_metrics:inc(<<"action:2">>, 'actions.success'), - ?assertEqual(1, emqx_rule_metrics:get(<<"action:1">>, 'actions.success')), - ?assertEqual(1, emqx_rule_metrics:get(<<"action:1">>, 'actions.failure')), - ?assertEqual(2, emqx_rule_metrics:get(<<"action:2">>, 'actions.success')), - ?assertEqual(0, emqx_rule_metrics:get(<<"action:3">>, 'actions.success')), - ?assertEqual(3, emqx_rule_metrics:get_overall('actions.success')), - ?assertEqual(1, emqx_rule_metrics:get_overall('actions.failure')). + ?assertEqual(0, emqx_rule_metrics:get_actions_taken(<<"action:1">>)), + ?assertEqual(0, emqx_rule_metrics:get_actions_exception(<<"action:1">>)), + ?assertEqual(0, emqx_rule_metrics:get_actions_taken(<<"action:2">>)), + ok = emqx_rule_metrics:create_metrics(<<"action:1">>), + ok = emqx_rule_metrics:create_metrics(<<"action:2">>), + ok = emqx_rule_metrics:inc_actions_taken(<<"action:1">>), + ok = emqx_rule_metrics:inc_actions_exception(<<"action:1">>), + ok = emqx_rule_metrics:inc_actions_taken(<<"action:2">>), + ok = emqx_rule_metrics:inc_actions_taken(<<"action:2">>), + ?assertEqual(1, emqx_rule_metrics:get_actions_taken(<<"action:1">>)), + ?assertEqual(1, emqx_rule_metrics:get_actions_exception(<<"action:1">>)), + ?assertEqual(2, emqx_rule_metrics:get_actions_taken(<<"action:2">>)), + ?assertEqual(0, emqx_rule_metrics:get_actions_taken(<<"action:3">>)), + ?assertEqual(3, emqx_rule_metrics:get_overall('actions.taken')), + ?assertEqual(1, emqx_rule_metrics:get_overall('actions.exception')), + ok = emqx_rule_metrics:clear_metrics(<<"action:1">>), + ok = emqx_rule_metrics:clear_metrics(<<"action:2">>), + ?assertEqual(0, emqx_rule_metrics:get_actions_taken(<<"action:1">>)), + ?assertEqual(0, emqx_rule_metrics:get_actions_taken(<<"action:2">>)). t_rule(_) -> + ok = emqx_rule_metrics:create_rule_metrics(<<"rule:1">>), + ok = emqx_rule_metrics:create_rule_metrics(<<"rule2">>), ok = emqx_rule_metrics:inc(<<"rule:1">>, 'rules.matched'), - ok = emqx_rule_metrics:inc(<<"rule:2">>, 'rules.matched'), - ok = emqx_rule_metrics:inc(<<"rule:2">>, 'rules.matched'), + ok = emqx_rule_metrics:inc(<<"rule2">>, 'rules.matched'), + ok = emqx_rule_metrics:inc(<<"rule2">>, 'rules.matched'), ?assertEqual(1, emqx_rule_metrics:get(<<"rule:1">>, 'rules.matched')), - ?assertEqual(2, emqx_rule_metrics:get(<<"rule:2">>, 'rules.matched')), - ?assertEqual(0, emqx_rule_metrics:get(<<"rule:3">>, 'rules.matched')), - ?assertEqual(3, emqx_rule_metrics:get_overall('rules.matched')). + ?assertEqual(2, emqx_rule_metrics:get(<<"rule2">>, 'rules.matched')), + ?assertEqual(0, emqx_rule_metrics:get(<<"rule3">>, 'rules.matched')), + ?assertEqual(3, emqx_rule_metrics:get_overall('rules.matched')), + ok = emqx_rule_metrics:clear_rule_metrics(<<"rule:1">>), + ok = emqx_rule_metrics:clear_rule_metrics(<<"rule2">>). t_clear(_) -> - ok = emqx_rule_metrics:inc(<<"action:1">>, 'actions.success'), - ?assertEqual(1, emqx_rule_metrics:get(<<"action:1">>, 'actions.success')), - ok = emqx_rule_metrics:clear(<<"action:1">>), - ?assertEqual(0, emqx_rule_metrics:get(<<"action:1">>, 'actions.success')). + ok = emqx_rule_metrics:create_metrics(<<"action:1">>), + ok = emqx_rule_metrics:inc_actions_taken(<<"action:1">>), + ?assertEqual(1, emqx_rule_metrics:get_actions_taken(<<"action:1">>)), + ok = emqx_rule_metrics:clear_metrics(<<"action:1">>), + ?assertEqual(0, emqx_rule_metrics:get_actions_taken(<<"action:1">>)). rule_speed(_) -> - ok = emqx_rule_metrics:inc(<<"rule:1">>, 'rules.matched'), - ok = emqx_rule_metrics:inc(<<"rule:1">>, 'rules.matched'), + ok = emqx_rule_metrics:create_rule_metrics(<<"rule1">>), + ok = emqx_rule_metrics:create_rule_metrics(<<"rule:2">>), + ok = emqx_rule_metrics:inc(<<"rule1">>, 'rules.matched'), + ok = emqx_rule_metrics:inc(<<"rule1">>, 'rules.matched'), ok = emqx_rule_metrics:inc(<<"rule:2">>, 'rules.matched'), - ?assertEqual(2, emqx_rule_metrics:get(<<"rule:1">>, 'rules.matched')), + ?assertEqual(2, emqx_rule_metrics:get(<<"rule1">>, 'rules.matched')), ct:sleep(1000), - ?LET(#{max := Max, current := Current}, emqx_rule_metrics:get_rule_speed(<<"rule:1">>), + ?LET(#{max := Max, current := Current}, emqx_rule_metrics:get_rule_speed(<<"rule1">>), {?assert(Max =< 2), ?assert(Current =< 2)}), ct:pal("===== Speed: ~p~n", [emqx_rule_metrics:get_overall_rule_speed()]), @@ -103,7 +120,7 @@ rule_speed(_) -> {?assert(Max =< 3), ?assert(Current =< 3)}), ct:sleep(2100), - ?LET(#{max := Max, current := Current, last5m := Last5Min}, emqx_rule_metrics:get_rule_speed(<<"rule:1">>), + ?LET(#{max := Max, current := Current, last5m := Last5Min}, emqx_rule_metrics:get_rule_speed(<<"rule1">>), {?assert(Max =< 2), ?assert(Current == 0), ?assert(Last5Min =< 0.67)}), @@ -115,7 +132,9 @@ rule_speed(_) -> ?LET(#{max := Max, current := Current, last5m := Last5Min}, emqx_rule_metrics:get_overall_rule_speed(), {?assert(Max =< 3), ?assert(Current == 0), - ?assert(Last5Min == 0)}). + ?assert(Last5Min == 0)}), + ok = emqx_rule_metrics:clear_rule_metrics(<<"rule1">>), + ok = emqx_rule_metrics:clear_rule_metrics(<<"rule:2">>). % t_create(_) -> % error('TODO'). diff --git a/apps/emqx_rule_engine/test/emqx_rule_utils_SUITE.erl b/apps/emqx_rule_engine/test/emqx_rule_utils_SUITE.erl index 73cedfb8f..f6f71ba68 100644 --- a/apps/emqx_rule_engine/test/emqx_rule_utils_SUITE.erl +++ b/apps/emqx_rule_engine/test/emqx_rule_utils_SUITE.erl @@ -25,13 +25,6 @@ all() -> emqx_ct:all(?MODULE). -t_preproc_sql(_) -> - Selected = #{a => <<"1">>, b => 1, c => 1.0, d => #{d1 => <<"hi">>}}, - {PrepareStatement, GetPrepareParams} = emqx_rule_utils:preproc_sql(<<"a:${a},b:${b},c:${c},d:${d}">>, '?'), - ?assertEqual(<<"a:?,b:?,c:?,d:?">>, PrepareStatement), - ?assertEqual([<<"1">>,1,1.0,<<"{\"d1\":\"hi\"}">>], - GetPrepareParams(Selected)). - t_http_connectivity(_) -> {ok, Socket} = gen_tcp:listen(?PORT, []), ok = emqx_rule_utils:http_connectivity("http://127.0.0.1:"++emqx_rule_utils:str(?PORT), 1000), @@ -84,3 +77,36 @@ t_proc_tmpl(_) -> Tks = emqx_rule_utils:preproc_tmpl(<<"a:${a},b:${b},c:${c},d:${d}">>), ?assertEqual(<<"a:1,b:1,c:1.0,d:{\"d1\":\"hi\"}">>, emqx_rule_utils:proc_tmpl(Tks, Selected)). + +t_proc_tmpl1(_) -> + Selected = #{a => <<"1">>, b => 1, c => 1.0, d => #{d1 => <<"hi">>}}, + Tks = emqx_rule_utils:preproc_tmpl(<<"a:$a,b:b},c:{c},d:${d">>), + ?assertEqual(<<"a:$a,b:b},c:{c},d:${d">>, + emqx_rule_utils:proc_tmpl(Tks, Selected)). + +t_proc_cmd(_) -> + Selected = #{v0 => <<"x">>, v1 => <<"1">>, v2 => #{d1 => <<"hi">>}}, + Tks = emqx_rule_utils:preproc_cmd(<<"hset name a:${v0} ${v1} b ${v2} ">>), + ?assertEqual([<<"hset">>, <<"name">>, + <<"a:x">>, <<"1">>, + <<"b">>, <<"{\"d1\":\"hi\"}">>], + emqx_rule_utils:proc_cmd(Tks, Selected)). + +t_preproc_sql(_) -> + Selected = #{a => <<"1">>, b => 1, c => 1.0, d => #{d1 => <<"hi">>}}, + {PrepareStatement, ParamsTokens} = emqx_rule_utils:preproc_sql(<<"a:${a},b:${b},c:${c},d:${d}">>, '?'), + ?assertEqual(<<"a:?,b:?,c:?,d:?">>, PrepareStatement), + ?assertEqual([<<"1">>,1,1.0,<<"{\"d1\":\"hi\"}">>], + emqx_rule_utils:proc_sql(ParamsTokens, Selected)). + +t_preproc_sql1(_) -> + Selected = #{a => <<"1">>, b => 1, c => 1.0, d => #{d1 => <<"hi">>}}, + {PrepareStatement, ParamsTokens} = emqx_rule_utils:preproc_sql(<<"a:${a},b:${b},c:${c},d:${d}">>, '$n'), + ?assertEqual(<<"a:$1,b:$2,c:$3,d:$4">>, PrepareStatement), + ?assertEqual([<<"1">>,1,1.0,<<"{\"d1\":\"hi\"}">>], + emqx_rule_utils:proc_sql(ParamsTokens, Selected)). +t_preproc_sql2(_) -> + Selected = #{a => <<"1">>, b => 1, c => 1.0, d => #{d1 => <<"hi">>}}, + {PrepareStatement, ParamsTokens} = emqx_rule_utils:preproc_sql(<<"a:$a,b:b},c:{c},d:${d">>, '?'), + ?assertEqual(<<"a:$a,b:b},c:{c},d:${d">>, PrepareStatement), + ?assertEqual([], emqx_rule_utils:proc_sql(ParamsTokens, Selected)). diff --git a/apps/emqx_web_hook/rebar.config b/apps/emqx_web_hook/rebar.config index 9da7778de..f0e99e681 100644 --- a/apps/emqx_web_hook/rebar.config +++ b/apps/emqx_web_hook/rebar.config @@ -25,7 +25,7 @@ {deps, [{emqx_ct_helper, {git, "https://github.com/emqx/emqx-ct-helpers", {tag, "1.3.0"}}}, {cuttlefish, {git, "https://github.com/emqx/cuttlefish", {tag, "v3.0.0"}}}, - {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.0"}}} + {emqtt, {git, "https://github.com/emqx/emqtt", {tag, "1.2.3"}}} ]} ]} ]}. diff --git a/apps/emqx_web_hook/src/emqx_web_hook.appup.src b/apps/emqx_web_hook/src/emqx_web_hook.appup.src index 55f99d668..dcf0d8cdd 100644 --- a/apps/emqx_web_hook/src/emqx_web_hook.appup.src +++ b/apps/emqx_web_hook/src/emqx_web_hook.appup.src @@ -1,36 +1,9 @@ %% -*-: erlang -*- - -{"4.2.3", - [ - {"4.2.2", [ - {load_module, emqx_web_hook_actions, brutal_purge, soft_purge, []}, - {apply, {emqx_rule_engine, load_providers, []}} - ]}, - {"4.2.1", [ - {load_module, emqx_web_hook, brutal_purge, soft_purge, []}, - {load_module, emqx_web_hook_actions, brutal_purge, soft_purge, [emqx_rule_engine]}, - {apply, {emqx_rule_engine, load_providers, []}} - ]}, - {"4.2.0", [ - {load_module, emqx_web_hook, brutal_purge, soft_purge, []}, - {load_module, emqx_web_hook_actions, brutal_purge, soft_purge, [emqx_rule_engine]}, - {apply, {emqx_rule_engine, load_providers, []}} - ]} - ], - [ - {"4.2.2", [ - {load_module, emqx_web_hook_actions, brutal_purge, soft_purge, []}, - {apply, {emqx_rule_engine, load_providers, []}} - ]}, - {"4.2.1", [ - {load_module, emqx_web_hook, brutal_purge, soft_purge, []}, - {load_module, emqx_web_hook_actions, brutal_purge, soft_purge, [emqx_rule_engine]}, - {apply, {emqx_rule_engine, load_providers, []}} - ]}, - {"4.2.0", [ - {load_module, emqx_web_hook, brutal_purge, soft_purge, []}, - {load_module, emqx_web_hook_actions, brutal_purge, soft_purge, [emqx_rule_engine]}, - {apply, {emqx_rule_engine, load_providers, []}} - ]} - ] +{VSN, + [ + {<<".*">>, []} + ], + [ + {<<".*">>, []} + ] }. diff --git a/apps/emqx_web_hook/src/emqx_web_hook_actions.erl b/apps/emqx_web_hook/src/emqx_web_hook_actions.erl index 2b0cac618..dd07d10a7 100644 --- a/apps/emqx_web_hook/src/emqx_web_hook_actions.erl +++ b/apps/emqx_web_hook/src/emqx_web_hook_actions.erl @@ -19,6 +19,7 @@ -include_lib("emqx/include/emqx.hrl"). -include_lib("emqx/include/logger.hrl"). +-include_lib("emqx_rule_engine/include/rule_actions.hrl"). -define(RESOURCE_TYPE_WEBHOOK, 'web_hook'). -define(RESOURCE_CONFIG_SPEC, #{ @@ -132,6 +133,7 @@ ]). -export([ on_action_create_data_to_webserver/2 + , on_action_data_to_webserver/2 ]). %%------------------------------------------------------------------------------ @@ -165,15 +167,25 @@ on_resource_destroy(_ResId, _Params) -> %% An action that forwards publish messages to a remote web server. -spec(on_action_create_data_to_webserver(Id::binary(), #{url() := string()}) -> action_fun()). -on_action_create_data_to_webserver(_Id, Params) -> +on_action_create_data_to_webserver(Id, Params) -> #{url := Url, headers := Headers, method := Method, content_type := ContentType, payload_tmpl := PayloadTmpl, path := Path} = parse_action_params(Params), - PayloadTks = emqx_rule_utils:preproc_tmpl(PayloadTmpl), PathTks = emqx_rule_utils:preproc_tmpl(Path), - fun(Selected, _Envs) -> - FullUrl = Url ++ emqx_rule_utils:proc_tmpl(PathTks, Selected), - http_request(FullUrl, Headers, Method, ContentType, format_msg(PayloadTks, Selected)) - end. + PayloadTks = emqx_rule_utils:preproc_tmpl(PayloadTmpl), + Params. + +on_action_data_to_webserver(Selected, _Envs = + #{?BINDING_KEYS := #{ + 'Id' := Id, + 'Url' := Url, + 'Headers' := Headers, + 'Method' := Method, + 'ContentType' := ContentType, + 'PathTks' := PathTks, + 'PayloadTks' := PayloadTks + }}) -> + FullUrl = Url ++ emqx_rule_utils:proc_tmpl(PathTks, Selected), + http_request(Id, FullUrl, Headers, Method, ContentType, format_msg(PayloadTks, Selected)). format_msg([], Data) -> emqx_json:encode(Data); @@ -190,14 +202,15 @@ create_req(get, Url, Headers, _, _) -> create_req(_, Url, Headers, ContentType, Body) -> {(Url), (Headers), binary_to_list(ContentType), (Body)}. -http_request(Url, Headers, Method, ContentType, Params) -> +http_request(ActId, Url, Headers, Method, ContentType, Params) -> logger:debug("[WebHook Action] ~s to ~s, headers: ~p, content-type: ~p, body: ~p", [Method, Url, Headers, ContentType, Params]), case do_http_request(Method, create_req(Method, Url, Headers, ContentType, Params), [{timeout, 5000}], [], 0) of - {ok, _} -> ok; + {ok, _} -> + emqx_rule_metrics:inc_actions_success(ActId); {error, Reason} -> logger:error("[WebHook Action] HTTP request error: ~p", [Reason]), - error({http_request_error, Reason}) + emqx_rule_metrics:inc_actions_error(ActId) end. do_http_request(Method, Req, HTTPOpts, Opts, Times) ->